@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.
- package/README.md +54 -0
- package/dist/migrations/005.d.ts +3 -0
- package/dist/migrations/005.js +19 -0
- package/dist/migrations/006.d.ts +3 -0
- package/dist/migrations/006.js +9 -0
- package/dist/migrations/007.d.ts +3 -0
- package/dist/migrations/007.js +10 -0
- package/dist/src/annotations/quote.d.ts +32 -0
- package/dist/src/annotations/quote.js +135 -0
- package/dist/src/annotations/store.d.ts +50 -0
- package/dist/src/annotations/store.js +89 -0
- package/dist/src/app/source.d.ts +2 -0
- package/dist/src/app/source.js +13 -17
- package/dist/src/cli/arguments.js +24 -7
- package/dist/src/cli/digest.d.ts +10 -0
- package/dist/src/cli/digest.js +63 -0
- package/dist/src/cli/import.d.ts +11 -0
- package/dist/src/cli/import.js +10 -0
- package/dist/src/cli/indexing.d.ts +3 -0
- package/dist/src/cli/indexing.js +5 -2
- package/dist/src/cli/main.js +94 -8
- package/dist/src/cli/runtimes.d.ts +10 -0
- package/dist/src/cli/runtimes.js +32 -0
- package/dist/src/digest/editorial.d.ts +54 -0
- package/dist/src/digest/editorial.js +87 -0
- package/dist/src/digest/endpoint.d.ts +20 -0
- package/dist/src/digest/endpoint.js +103 -0
- package/dist/src/digest/pipeline.d.ts +148 -0
- package/dist/src/digest/pipeline.js +399 -0
- package/dist/src/digest/schema.d.ts +126 -0
- package/dist/src/digest/schema.js +138 -0
- package/dist/src/digest/store.d.ts +57 -0
- package/dist/src/digest/store.js +79 -0
- package/dist/src/identity/query-policy.d.ts +21 -0
- package/dist/src/identity/query-policy.js +55 -0
- package/dist/src/identity/uri.d.ts +30 -0
- package/dist/src/identity/uri.js +94 -0
- package/dist/src/identity/visibility.d.ts +24 -0
- package/dist/src/identity/visibility.js +75 -0
- package/dist/src/importers/apply.d.ts +10 -0
- package/dist/src/importers/apply.js +25 -22
- package/dist/src/importers/jungle-digest.d.ts +107 -0
- package/dist/src/importers/jungle-digest.js +196 -0
- package/dist/src/importers/jungle-store.d.ts +45 -0
- package/dist/src/importers/jungle-store.js +96 -0
- package/dist/src/importers/jungle.d.ts +70 -0
- package/dist/src/importers/jungle.js +142 -0
- package/dist/src/indexing/index.d.ts +7 -0
- package/dist/src/indexing/index.js +31 -8
- package/dist/src/indexing/prune-run.d.ts +22 -0
- package/dist/src/indexing/prune-run.js +67 -0
- package/dist/src/indexing/prune.d.ts +30 -0
- package/dist/src/indexing/prune.js +95 -0
- package/dist/src/indexing/runner.js +5 -5
- package/dist/src/inference/embedding/index.d.ts +25 -0
- package/dist/src/inference/embedding/index.js +11 -0
- package/dist/src/models/artifacts.js +175 -33
- package/dist/src/portability/backup.js +3 -1
- package/dist/src/portability/database.js +3 -0
- package/dist/src/publish/component.d.ts +8 -0
- package/dist/src/publish/component.js +114 -0
- package/dist/src/publish/feed.d.ts +24 -0
- package/dist/src/publish/feed.js +77 -0
- package/dist/src/publish/identity.d.ts +6 -0
- package/dist/src/publish/identity.js +68 -0
- package/dist/src/publish/index.d.ts +34 -0
- package/dist/src/publish/index.js +137 -0
- package/dist/src/publish/projection.d.ts +29 -0
- package/dist/src/publish/projection.js +43 -0
- package/dist/src/search/hybrid.js +2 -2
- package/dist/src/search/lexical.d.ts +11 -0
- package/dist/src/search/lexical.js +6 -0
- package/dist/src/search/query.d.ts +7 -3
- package/dist/src/search/query.js +52 -33
- package/dist/src/search/vector.d.ts +1 -1
- package/dist/src/search/vector.js +6 -5
- package/dist/src/store/changes.d.ts +42 -0
- package/dist/src/store/changes.js +178 -0
- package/dist/src/store/foundation.d.ts +7 -0
- package/dist/src/store/foundation.js +7 -0
- package/dist/src/store/index.d.ts +69 -0
- package/dist/src/store/index.js +134 -51
- package/dist/src/worker/runtime-gc.d.ts +30 -0
- package/dist/src/worker/runtime-gc.js +296 -0
- package/native/runtime-manifest.json +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,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
|
+
}
|
package/dist/src/cli/import.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/cli/import.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/cli/indexing.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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 }) };
|
package/dist/src/cli/main.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|