@hraness/sponge 0.4.0 → 0.5.0
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 +16 -16
- package/dist/src/acquisition/capture.d.ts +1 -4
- package/dist/src/acquisition/capture.js +12 -23
- package/dist/src/acquisition/clip-helper.js +14 -0
- package/dist/src/acquisition/errors.d.ts +2 -1
- package/dist/src/acquisition/errors.js +4 -33
- package/dist/src/acquisition/lock.d.ts +1 -1
- package/dist/src/acquisition/lock.js +1 -1
- package/dist/src/acquisition/pins.d.ts +1 -1
- package/dist/src/acquisition/pins.js +3 -9
- package/dist/src/acquisition/process.d.ts +1 -1
- package/dist/src/acquisition/process.js +1 -2
- package/dist/src/acquisition/runtime.d.ts +0 -4
- package/dist/src/acquisition/runtime.js +12 -18
- package/dist/src/cli/commands.js +34 -16
- package/dist/src/cli/config.d.ts +2 -1
- package/dist/src/cli/config.js +8 -8
- package/dist/src/cli/runtimes.js +1 -1
- package/dist/src/cli/worker.js +13 -10
- package/dist/src/digest/pipeline.d.ts +29 -4
- package/dist/src/digest/pipeline.js +125 -21
- package/dist/src/errors.d.ts +2 -0
- package/dist/src/errors.js +4 -0
- package/dist/src/media/capture.d.ts +6 -2
- package/dist/src/media/capture.js +33 -42
- package/dist/src/models/catalog.d.ts +4 -2
- package/dist/src/models/catalog.js +39 -20
- package/dist/src/models/runtime.d.ts +11 -3
- package/dist/src/models/runtime.js +18 -11
- package/dist/src/pdf/capture-helper.js +6 -5
- package/dist/src/pdf/capture.d.ts +0 -4
- package/dist/src/pdf/capture.js +2 -14
- package/dist/src/pdf/errors.js +1 -3
- package/dist/src/store/digest-jobs.d.ts +5 -2
- package/dist/src/store/digest-jobs.js +7 -3
- package/dist/src/store/revisions.js +1 -1
- package/dist/src/worker/basic.d.ts +1 -1
- package/dist/src/worker/basic.js +5 -3
- package/native/runtime-manifest.json +14 -6
- package/package.json +1 -1
- package/dist/src/media/capture-helper.js +0 -21
- /package/dist/src/{media/capture-helper.d.ts → acquisition/clip-helper.d.ts} +0 -0
|
@@ -12,7 +12,7 @@ const installDeadlineMs = 30 * 60 * 1000;
|
|
|
12
12
|
function checkPins(pins) {
|
|
13
13
|
if (!/^[a-z0-9][a-z0-9.-]{0,99}$/.test(pins.id) || hash(pins.lockfile) !== pins.lockSha256)
|
|
14
14
|
throw new SpongeError('invalid-input');
|
|
15
|
-
for (const pin of [pins.bun, pins.
|
|
15
|
+
for (const pin of [pins.bun, pins.wordcell]) {
|
|
16
16
|
let url;
|
|
17
17
|
try {
|
|
18
18
|
url = new URL(pin.url);
|
|
@@ -36,7 +36,7 @@ function checkPins(pins) {
|
|
|
36
36
|
}
|
|
37
37
|
export function captureSetupPlan(pins) {
|
|
38
38
|
checkPins(pins);
|
|
39
|
-
const artifacts = [pins.bun, pins.
|
|
39
|
+
const artifacts = [pins.bun, pins.wordcell], downloadBytes = artifacts.reduce((n, a) => n + a.bytes, 0);
|
|
40
40
|
return {
|
|
41
41
|
artifacts,
|
|
42
42
|
downloadBytes,
|
|
@@ -46,7 +46,7 @@ export function captureSetupPlan(pins) {
|
|
|
46
46
|
installationDeadlineMs: installDeadlineMs,
|
|
47
47
|
dependencyDownloads: 'frozen-lock',
|
|
48
48
|
requiredFreeBytes: 2 * 1024 ** 3 + budget + stagingBudget + downloadBytes,
|
|
49
|
-
capabilities: ['article-http', 'selected-
|
|
49
|
+
capabilities: ['article-http', 'selected-attached-browser', 'verified-offline-images'],
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
52
|
async function download(pin, path, signal) {
|
|
@@ -116,7 +116,7 @@ export const runtimeVerification = { fullWalks: 0 };
|
|
|
116
116
|
async function inspectRuntime(root, pins) {
|
|
117
117
|
checkPins(pins);
|
|
118
118
|
await privateRoot(root);
|
|
119
|
-
const key = JSON.stringify([root, pins.id, pins.lockSha256, pins.bun.archiveExecutable, pins.bun.executableSha256]), bun = join(root, pins.bun.archiveExecutable)
|
|
119
|
+
const key = JSON.stringify([root, pins.id, pins.lockSha256, pins.bun.archiveExecutable, pins.bun.executableSha256]), bun = join(root, pins.bun.archiveExecutable);
|
|
120
120
|
// Stat receipt of the root, its receipt file and every inventoried entry. Each stat precedes any read of its entry, so a later change always shows.
|
|
121
121
|
const head = [
|
|
122
122
|
statLine('.', await lstat(root, { bigint: true })),
|
|
@@ -124,7 +124,7 @@ async function inspectRuntime(root, pins) {
|
|
|
124
124
|
];
|
|
125
125
|
const cached = verified.get(key);
|
|
126
126
|
if (cached !== undefined && cached === hash([...head, ...(await inventory(root, false)).stats].join('\n')))
|
|
127
|
-
return { root, bun,
|
|
127
|
+
return { root, bun, pins };
|
|
128
128
|
verified.delete(key);
|
|
129
129
|
runtimeVerification.fullWalks++;
|
|
130
130
|
const receipt = JSON.parse((await readConfined(root, 'runtime-receipt.json', 4096)).toString()), walked = await inventory(root);
|
|
@@ -136,7 +136,7 @@ async function inspectRuntime(root, pins) {
|
|
|
136
136
|
if (hash(await readConfined(root, pins.bun.archiveExecutable, 128 * 1024 ** 2)) !== pins.bun.executableSha256)
|
|
137
137
|
throw new SpongeError('integrity');
|
|
138
138
|
verified.set(key, hash([...head, ...walked.stats].join('\n')));
|
|
139
|
-
return { root, bun,
|
|
139
|
+
return { root, bun, pins };
|
|
140
140
|
}
|
|
141
141
|
async function provisionRuntime(options) {
|
|
142
142
|
const { pins, signal } = options, plan = captureSetupPlan(pins);
|
|
@@ -188,7 +188,7 @@ async function provisionRuntime(options) {
|
|
|
188
188
|
await download(pins.bun, join(stage, 'bun.zip'), controller.signal);
|
|
189
189
|
const unpack = await runOwned('/usr/bin/unzip', ['-q', join(stage, 'bun.zip'), '-d', stage], {
|
|
190
190
|
cwd: stage,
|
|
191
|
-
env: isolatedEnvironment(home, home
|
|
191
|
+
env: isolatedEnvironment(home, home),
|
|
192
192
|
signal: controller.signal,
|
|
193
193
|
deadlineMs: 30000,
|
|
194
194
|
maxOutputBytes: 65536,
|
|
@@ -200,16 +200,15 @@ async function provisionRuntime(options) {
|
|
|
200
200
|
if (hash(await readConfined(stage, pins.bun.archiveExecutable, 128 * 1024 ** 2)) !== pins.bun.executableSha256)
|
|
201
201
|
throw new SpongeError('integrity');
|
|
202
202
|
await chmod(bun, 0o700);
|
|
203
|
-
await download(pins.ghostget, join(stage, `hraness-ghostget-${pins.ghostget.version}.tgz`), controller.signal);
|
|
204
203
|
await download(pins.wordcell, join(stage, `hraness-wordcell-${pins.wordcell.version}.tgz`), controller.signal);
|
|
205
204
|
await writeFile(join(stage, 'package.json'), JSON.stringify({
|
|
206
205
|
private: true,
|
|
207
|
-
dependencies: { '@hraness/
|
|
206
|
+
dependencies: { '@hraness/wordcell': `./hraness-wordcell-${pins.wordcell.version}.tgz` },
|
|
208
207
|
}), { mode: 0o600 });
|
|
209
208
|
await writeFile(join(stage, 'bun.lock'), pins.lockfile, { mode: 0o600 });
|
|
210
209
|
const install = await runOwned(bun, ['install', '--frozen-lockfile', '--ignore-scripts'], {
|
|
211
210
|
cwd: stage,
|
|
212
|
-
env: isolatedEnvironment(home, home
|
|
211
|
+
env: isolatedEnvironment(home, home),
|
|
213
212
|
signal: controller.signal,
|
|
214
213
|
deadlineMs: installDeadlineMs,
|
|
215
214
|
maxOutputBytes: 65536,
|
|
@@ -219,14 +218,9 @@ async function provisionRuntime(options) {
|
|
|
219
218
|
throw new SpongeError(limitExceeded ? 'limit-exceeded' : (install.stopped ?? 'unavailable'));
|
|
220
219
|
await rm(home, { recursive: true, force: true });
|
|
221
220
|
await rm(join(stage, 'bun.zip'));
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
]) {
|
|
226
|
-
const pkg = JSON.parse((await readConfined(stage, `node_modules/@hraness/${name}/package.json`, 65536)).toString());
|
|
227
|
-
if (pkg.version !== pin.version)
|
|
228
|
-
throw new SpongeError('integrity');
|
|
229
|
-
}
|
|
221
|
+
const pkg = JSON.parse((await readConfined(stage, 'node_modules/@hraness/wordcell/package.json', 65536)).toString());
|
|
222
|
+
if (pkg.version !== pins.wordcell.version)
|
|
223
|
+
throw new SpongeError('integrity');
|
|
230
224
|
await writeFile(join(stage, 'runtime-receipt.json'), JSON.stringify({
|
|
231
225
|
schemaVersion: 1,
|
|
232
226
|
pinId: pins.id,
|
package/dist/src/cli/commands.js
CHANGED
|
@@ -8,17 +8,18 @@ import { queueAction } from './queue.js';
|
|
|
8
8
|
import { portabilityAction } from './portability.js';
|
|
9
9
|
import { restoreLibrary } from '../portability/backup.js';
|
|
10
10
|
import { workerAction, serveWorker, hasDueAcquisition, workerDoctor } from './worker.js';
|
|
11
|
-
import { nativeSetupPlan, provisionNative, inspectNative } from '../models/runtime.js';
|
|
11
|
+
import { nativeSetupPlan, provisionNative, inspectNative, optionalNative } from '../models/runtime.js';
|
|
12
12
|
import { digestModel, digestSetupPlan, inspectDigest, provisionDigest } from '../models/digest.js';
|
|
13
13
|
import { withDigestServer } from '../digest/server.js';
|
|
14
14
|
import { nativeDownloadAuthGuidance } from '../models/download.js';
|
|
15
|
+
import { nativePlatformSupported } from '../models/catalog.js';
|
|
15
16
|
import { drainIndexQueue } from '../indexing/runner.js';
|
|
16
17
|
import { indexAction, overviewText } from './indexing.js';
|
|
17
18
|
import { pruneText } from '../indexing/prune-run.js';
|
|
18
19
|
import { importAction, jungleAction, jungleText } from './import.js';
|
|
19
20
|
import { digestText, reviewFromSpec, reviewSpec } from './digest.js';
|
|
20
21
|
import { connectEndpoint } from '../digest/endpoint.js';
|
|
21
|
-
import { createDigest } from '../digest/pipeline.js';
|
|
22
|
+
import { createDigest, undigestible } from '../digest/pipeline.js';
|
|
22
23
|
import { createQueryEmbedder, createTextEmbedder } from '../search/query.js';
|
|
23
24
|
import { runtimeGcAction, runtimeGcText, setupRuntimeGc } from './runtimes.js';
|
|
24
25
|
import { drainLibrary } from '../worker/basic.js';
|
|
@@ -55,6 +56,8 @@ const resolveConnection = (connections) => (realm) => {
|
|
|
55
56
|
const connection = connections.find((c) => c.realm === realm);
|
|
56
57
|
if (!connection)
|
|
57
58
|
throw new SpongeError('repair-auth');
|
|
59
|
+
if (!connection.capture)
|
|
60
|
+
throw new SpongeError('connection-retired');
|
|
58
61
|
return connection.capture;
|
|
59
62
|
};
|
|
60
63
|
/** add/refresh with --wait and run --drain: drain acquisition (one job when given), then queued indexes for run. */
|
|
@@ -67,16 +70,9 @@ async function drain(c, jobId, connections) {
|
|
|
67
70
|
throw jobFailure(store, jobId);
|
|
68
71
|
}
|
|
69
72
|
const runtime = await captureRuntime(c);
|
|
70
|
-
let asrRuntime;
|
|
71
|
-
try {
|
|
72
|
-
asrRuntime = await inspectNative('asr', c.nativeRoot, c.version);
|
|
73
|
-
}
|
|
74
|
-
catch (error) {
|
|
75
|
-
if (!['runtime-missing', 'unsupported'].includes(publicCode(error, 'integrity')))
|
|
76
|
-
throw error;
|
|
77
|
-
}
|
|
78
73
|
const results = await drainLibrary(store, {
|
|
79
|
-
asrRuntime,
|
|
74
|
+
asrRuntime: await optionalNative('asr', c.nativeRoot, c.version),
|
|
75
|
+
mediaRuntime: await optionalNative('media', c.nativeRoot, c.version),
|
|
80
76
|
language: args.flags.language,
|
|
81
77
|
forceLocal: args.flags['force-local'] === true,
|
|
82
78
|
runtime,
|
|
@@ -154,8 +150,8 @@ export const commands = [
|
|
|
154
150
|
c.emit({ digest: 'ready', model: pin.alias, auto: settings.auto, gc }, `Digest model ready: ${pin.alias}. New captures are digested in the background; turn that off with \`sponge digest --auto off\`.`);
|
|
155
151
|
return 0;
|
|
156
152
|
}
|
|
157
|
-
|
|
158
|
-
const
|
|
153
|
+
const installNative = async (kind) => {
|
|
154
|
+
const plan = await nativeSetupPlan(kind, c.version);
|
|
159
155
|
process.stderr.write(JSON.stringify({ setup: kind, ...plan }) + '\n');
|
|
160
156
|
try {
|
|
161
157
|
await provisionNative(kind, c.nativeRoot, c.version, c.signal);
|
|
@@ -165,10 +161,20 @@ export const commands = [
|
|
|
165
161
|
process.stderr.write(nativeDownloadAuthGuidance + '\n');
|
|
166
162
|
throw error;
|
|
167
163
|
}
|
|
164
|
+
};
|
|
165
|
+
if (!c.args.flags.capture) {
|
|
166
|
+
const kind = c.args.flags.asr ? 'asr' : 'embedding';
|
|
167
|
+
await installNative(kind);
|
|
168
168
|
const gc = await setupRuntimeGc(c.store, c.version);
|
|
169
169
|
c.emit({ [kind]: 'ready', gc });
|
|
170
170
|
return 0;
|
|
171
171
|
}
|
|
172
|
+
// Media goes first so a running worker never drains media jobs against a half-finished setup.
|
|
173
|
+
let media = nativePlatformSupported() ? 'ready' : 'unsupported';
|
|
174
|
+
if (media === 'ready')
|
|
175
|
+
await installNative('media').catch((error) => {
|
|
176
|
+
media = codeOf(error);
|
|
177
|
+
});
|
|
172
178
|
const plan = captureSetupPlan(defaultCapturePins);
|
|
173
179
|
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`);
|
|
174
180
|
await provisionCapture({
|
|
@@ -176,8 +182,13 @@ export const commands = [
|
|
|
176
182
|
pins: defaultCapturePins,
|
|
177
183
|
signal: c.signal,
|
|
178
184
|
});
|
|
179
|
-
|
|
180
|
-
|
|
185
|
+
// Articles and PDFs run anywhere; the media helper is a native release asset and is best-effort.
|
|
186
|
+
const gc = await setupRuntimeGc(c.store, c.version), ready = media === 'ready'
|
|
187
|
+
? 'Capture ready'
|
|
188
|
+
: media === 'unsupported'
|
|
189
|
+
? 'Capture ready (media capture needs macOS 26 on Apple Silicon)'
|
|
190
|
+
: `Capture ready; media helper not installed (${media}). Articles and PDFs work; rerun \`sponge setup --capture\` for media.`;
|
|
191
|
+
c.emit({ capture: 'ready', media, gc }, 'skipped' in gc ? `${ready}\nRuntime GC skipped: ${gc.skipped}` : `${ready}\n${runtimeGcText(gc)}`);
|
|
181
192
|
return 0;
|
|
182
193
|
},
|
|
183
194
|
},
|
|
@@ -436,7 +447,7 @@ export const commands = [
|
|
|
436
447
|
capture = publicCode(error, 'runtime-missing');
|
|
437
448
|
}
|
|
438
449
|
const states = {};
|
|
439
|
-
for (const kind of ['asr', 'embedding']) {
|
|
450
|
+
for (const kind of ['asr', 'embedding', 'media']) {
|
|
440
451
|
try {
|
|
441
452
|
await inspectNative(kind, c.nativeRoot, c.version);
|
|
442
453
|
states[kind] = 'ready';
|
|
@@ -742,6 +753,13 @@ export const commands = [
|
|
|
742
753
|
return 0;
|
|
743
754
|
}
|
|
744
755
|
if (args.flags.draft) {
|
|
756
|
+
// Refuse an unknown item or a thin capture before the model server spends its startup on it.
|
|
757
|
+
const item = store.read(args.positional[0]);
|
|
758
|
+
if (!item)
|
|
759
|
+
throw new SpongeError('unavailable');
|
|
760
|
+
const thin = undigestible(item.markdown);
|
|
761
|
+
if (thin)
|
|
762
|
+
throw new SpongeError(thin);
|
|
745
763
|
const runtime = await inspectDigest(c.nativeRoot, args.flags.model ?? store.digests.settings().model ?? undefined), result = await withDigestServer(store.root, runtime, (model) => createDigest(store, args.positional[0], model, { signal }), { signal });
|
|
746
764
|
c.emit({ digest: result.derivation, duplicate: result.duplicate, ...(result.trace ? { trace: result.trace } : {}) }, digestText(result.derivation));
|
|
747
765
|
return 0;
|
package/dist/src/cli/config.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { CaptureConnection } from '../acquisition/index.js';
|
|
|
2
2
|
export interface Connection {
|
|
3
3
|
id: string;
|
|
4
4
|
realm: string;
|
|
5
|
-
|
|
5
|
+
/** Absent for a retired GhostGet account connection: it still names its items' realm, but cannot capture. */
|
|
6
|
+
capture?: CaptureConnection;
|
|
6
7
|
}
|
|
7
8
|
/** Local references only. Tokens, cookies and passwords never belong in this file. */
|
|
8
9
|
export declare function readConnections(root: string): Promise<Connection[]>;
|
package/dist/src/cli/config.js
CHANGED
|
@@ -32,17 +32,17 @@ export async function readConnections(root) {
|
|
|
32
32
|
if (!entry || typeof entry.id !== 'string' || !/^[a-zA-Z0-9_-]{1,64}$/.test(entry.id) || ids.has(entry.id))
|
|
33
33
|
throw new SpongeError('invalid-input');
|
|
34
34
|
ids.add(entry.id);
|
|
35
|
-
let capture;
|
|
36
35
|
if (entry.kind === 'account' &&
|
|
37
36
|
typeof entry.stateHome === 'string' &&
|
|
38
37
|
entry.stateHome.startsWith('/') &&
|
|
39
|
-
typeof entry.accountId === 'string')
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
typeof entry.accountId === 'string') {
|
|
39
|
+
// The realm keeps its original derivation, so items captured through it keep their access class.
|
|
40
|
+
const legacy = { kind: 'account', stateHome: entry.stateHome, accountId: entry.accountId };
|
|
41
|
+
return { id: entry.id, realm: 'connection:' + sha256(JSON.stringify([entry.id, legacy])) };
|
|
42
|
+
}
|
|
43
|
+
if (entry.kind !== 'cdp' || typeof entry.endpoint !== 'string')
|
|
44
44
|
throw new SpongeError('invalid-input');
|
|
45
|
-
const
|
|
46
|
-
return { id: entry.id, realm, capture };
|
|
45
|
+
const capture = { kind: 'cdp', endpoint: entry.endpoint };
|
|
46
|
+
return { id: entry.id, realm: 'connection:' + sha256(JSON.stringify([entry.id, capture])), capture };
|
|
47
47
|
});
|
|
48
48
|
}
|
package/dist/src/cli/runtimes.js
CHANGED
|
@@ -11,7 +11,7 @@ async function installedReferences(version) {
|
|
|
11
11
|
const catalog = await loadNativeCatalog(version);
|
|
12
12
|
models = {
|
|
13
13
|
fingerprints: [
|
|
14
|
-
...['asr', 'embedding'].map((kind) => modelSetupPlan(nativeModelPackage(catalog, kind)).fingerprint),
|
|
14
|
+
...['asr', 'embedding', 'media'].map((kind) => modelSetupPlan(nativeModelPackage(catalog, kind)).fingerprint),
|
|
15
15
|
...digestFingerprints(),
|
|
16
16
|
],
|
|
17
17
|
};
|
package/dist/src/cli/worker.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { inspectNative } from '../models/runtime.js';
|
|
2
|
+
import { inspectNative, optionalNative } from '../models/runtime.js';
|
|
3
3
|
import { drainIndexQueue } from '../indexing/runner.js';
|
|
4
4
|
import { drainDigestQueue } from '../digest/runner.js';
|
|
5
5
|
import { drainLibrary } from '../worker/basic.js';
|
|
@@ -63,15 +63,18 @@ export async function drainServiceQueues(store, options, version, signal) {
|
|
|
63
63
|
const runtime = options.runtime ?? (await options.resolveCaptureRuntime?.());
|
|
64
64
|
if (!runtime)
|
|
65
65
|
throw new SpongeError('runtime-missing');
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
66
|
+
const models = join(store.root, 'runtime', 'models');
|
|
67
|
+
// Media jobs report a missing helper themselves; articles and PDFs still drain.
|
|
68
|
+
const asrRuntime = options.asrRuntime ?? (await optionalNative('asr', models, version));
|
|
69
|
+
const mediaRuntime = options.mediaRuntime ?? (await optionalNative('media', models, version));
|
|
70
|
+
results.push(...(await drainLibrary(store, {
|
|
71
|
+
...options,
|
|
72
|
+
runtime,
|
|
73
|
+
asrRuntime,
|
|
74
|
+
mediaRuntime,
|
|
75
|
+
signal,
|
|
76
|
+
allowPrivateNetwork: false,
|
|
77
|
+
})));
|
|
75
78
|
}
|
|
76
79
|
catch (error) {
|
|
77
80
|
acquisitionError = error;
|
|
@@ -4,7 +4,7 @@ import type { Derivation, DigestCandidate } from './derivation.js';
|
|
|
4
4
|
* Model-agnostic Digest v2 drafting. The model sees numbered passages and cites a passage id plus a short exact
|
|
5
5
|
* span; code snaps, prunes and checks. Nothing leaves this module except through the injected model.
|
|
6
6
|
*/
|
|
7
|
-
export declare const PROMPT_VERSION = "sponge-digest-prompt-
|
|
7
|
+
export declare const PROMPT_VERSION = "sponge-digest-prompt-v6";
|
|
8
8
|
export type ChatMessage = {
|
|
9
9
|
role: 'system' | 'user' | 'assistant';
|
|
10
10
|
content: string;
|
|
@@ -56,8 +56,30 @@ interface Highlight {
|
|
|
56
56
|
* change digest input hashes.
|
|
57
57
|
*/
|
|
58
58
|
export declare function canonicalJson(value: unknown): string;
|
|
59
|
-
/**
|
|
60
|
-
export
|
|
59
|
+
/** Where a post starts in a captured thread, and who wrote it as the model is told. */
|
|
60
|
+
export interface Post {
|
|
61
|
+
start: number;
|
|
62
|
+
label: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The posts of an X thread capture: top-level list items that carry X's post links. The poster's handle comes from
|
|
66
|
+
* the item's avatar or name link; the thread's author from the title (`Name (@handle) on X`), else the first post. A
|
|
67
|
+
* later post without a handle is still a reply. Other list items (an X article's bullets) are not posts, and a thread
|
|
68
|
+
* with no reply has none.
|
|
69
|
+
*/
|
|
70
|
+
export declare function threadPosts(markdown: string): Post[];
|
|
71
|
+
/** A thread's passages without its replies, which are readers' comments rather than the source; renumbered from 1. */
|
|
72
|
+
export declare function authorPassages(markdown: string): Passage[];
|
|
73
|
+
/**
|
|
74
|
+
* Paragraph-level passages with raw offsets. Headings label passages; code fences and image-only blocks are not
|
|
75
|
+
* quotable. `posts` (a thread's) also break blocks, and their labels head the passages that follow.
|
|
76
|
+
*/
|
|
77
|
+
export declare function passages(markdown: string, maxChars?: number, posts?: readonly Post[]): Passage[];
|
|
78
|
+
/**
|
|
79
|
+
* Why a capture cannot be digested, as a detail code, or undefined when it can: its body (past the frontmatter, images
|
|
80
|
+
* dropped, link text kept) has under `minProseWords` words with letters, or only an archive's metadata was saved.
|
|
81
|
+
*/
|
|
82
|
+
export declare function undigestible(markdown: string): 'source-too-thin' | undefined;
|
|
61
83
|
/** Pack passages into sections of at most `budget` display characters, preferring heading boundaries past half a budget. */
|
|
62
84
|
export declare function sectionsOf(list: Passage[], budget: number): Passage[][];
|
|
63
85
|
/** JSON Schema for the model's draft. Tags become an enum when a vocabulary is given. */
|
|
@@ -160,8 +182,11 @@ export interface DigestStore {
|
|
|
160
182
|
read(kind: 'digest', id: string): Derivation | undefined;
|
|
161
183
|
};
|
|
162
184
|
}
|
|
185
|
+
type DraftOptions = Omit<DraftInput, 'revisionId' | 'markdown' | 'title' | 'form' | 'model' | 'highlights'>;
|
|
186
|
+
/** What the item's current revision is drafted from: its Markdown, title, form and anchored highlights. */
|
|
187
|
+
export declare function digestInputFor(store: Pick<DigestStore, 'read' | 'annotations'>, itemId: string, model: DigestModel, options?: DraftOptions): DraftInput;
|
|
163
188
|
/** Draft and store a digest for the item's current revision. A matching input hash returns the stored draft without calling the model. */
|
|
164
|
-
export declare function createDigest(store: DigestStore, itemId: string, model: DigestModel, options?:
|
|
189
|
+
export declare function createDigest(store: DigestStore, itemId: string, model: DigestModel, options?: DraftOptions): Promise<{
|
|
165
190
|
derivation: Derivation;
|
|
166
191
|
duplicate: boolean;
|
|
167
192
|
trace?: DraftTrace;
|
|
@@ -6,7 +6,7 @@ import { sha256 } from '../util/hash.js';
|
|
|
6
6
|
* Model-agnostic Digest v2 drafting. The model sees numbered passages and cites a passage id plus a short exact
|
|
7
7
|
* span; code snaps, prunes and checks. Nothing leaves this module except through the injected model.
|
|
8
8
|
*/
|
|
9
|
-
export const PROMPT_VERSION = 'sponge-digest-prompt-
|
|
9
|
+
export const PROMPT_VERSION = 'sponge-digest-prompt-v6';
|
|
10
10
|
/** 24k characters is roughly 6k tokens of passages, leaving room for instructions and output in an 8k context on an 8 GB Mac. */
|
|
11
11
|
const defaultDigestParams = {
|
|
12
12
|
temperature: 0,
|
|
@@ -40,14 +40,62 @@ export function canonicalJson(value) {
|
|
|
40
40
|
throw new SpongeError('invalid-input');
|
|
41
41
|
return JSON.stringify(value ?? null);
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
const postHandle = [
|
|
44
|
+
/^- \[!\[@(\w+)\]/,
|
|
45
|
+
/^- \[\[remote image: @(\w+)\]/,
|
|
46
|
+
/^-\s+\[[^\]]+\]\(https:\/\/x\.com\/(\w+)\)\s*$/,
|
|
47
|
+
];
|
|
48
|
+
/**
|
|
49
|
+
* The posts of an X thread capture: top-level list items that carry X's post links. The poster's handle comes from
|
|
50
|
+
* the item's avatar or name link; the thread's author from the title (`Name (@handle) on X`), else the first post. A
|
|
51
|
+
* later post without a handle is still a reply. Other list items (an X article's bullets) are not posts, and a thread
|
|
52
|
+
* with no reply has none.
|
|
53
|
+
*/
|
|
54
|
+
export function threadPosts(markdown) {
|
|
55
|
+
const author = /^title: ".*\(@(\w+)\) on X"/m.exec(frontmatter(markdown))?.[1], items = [];
|
|
56
|
+
let at = 0;
|
|
57
|
+
for (const line of markdown.split('\n')) {
|
|
58
|
+
if (/^- /.test(line) || /^-\s+\[/.test(line))
|
|
59
|
+
items.push({ start: at, handle: postHandle.map((r) => r.exec(line)?.[1]).find(Boolean), post: false });
|
|
60
|
+
const item = items.at(-1);
|
|
61
|
+
if (item && /x\.com\/(compose\/post\?in_reply_to=|\w+\/status\/\d)/.test(line))
|
|
62
|
+
item.post = true;
|
|
63
|
+
at += line.length + 1;
|
|
64
|
+
}
|
|
65
|
+
const posts = items.filter((i) => i.post || i.handle), by = author ?? posts[0]?.handle, same = (h) => h.toLowerCase() === by?.toLowerCase(), labelled = posts.map((p, i) => ({
|
|
66
|
+
start: p.start,
|
|
67
|
+
label: p.handle && !same(p.handle)
|
|
68
|
+
? `Reply by @${p.handle}`
|
|
69
|
+
: p.handle || i === 0
|
|
70
|
+
? `Post by ${by ? `@${by}, ` : ''}the author`
|
|
71
|
+
: 'Reply (poster not captured)',
|
|
72
|
+
}));
|
|
73
|
+
// With no reply there is nobody to tell apart, so the passages stay as they are.
|
|
74
|
+
return labelled.some((p) => p.label !== labelled[0].label) ? labelled : [];
|
|
75
|
+
}
|
|
76
|
+
/** A thread's passages without its replies, which are readers' comments rather than the source; renumbered from 1. */
|
|
77
|
+
export function authorPassages(markdown) {
|
|
78
|
+
return passages(markdown, undefined, threadPosts(markdown))
|
|
79
|
+
.filter((p) => !p.heading?.startsWith('Reply'))
|
|
80
|
+
.map((p, i) => ({ ...p, id: i + 1 }));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Paragraph-level passages with raw offsets. Headings label passages; code fences and image-only blocks are not
|
|
84
|
+
* quotable. `posts` (a thread's) also break blocks, and their labels head the passages that follow.
|
|
85
|
+
*/
|
|
86
|
+
export function passages(markdown, maxChars = 1200, posts = []) {
|
|
45
87
|
const out = [], blocks = [];
|
|
46
88
|
let at = 0, start = -1, fence = false, fenced = false;
|
|
89
|
+
const breaks = new Set(posts.map((p) => p.start));
|
|
47
90
|
for (const line of markdown.split('\n')) {
|
|
48
91
|
const end = at + line.length, blank = !line.trim(), marker = /^\s{0,3}(```|~~~)/.test(line);
|
|
49
92
|
if (marker)
|
|
50
93
|
fence = !fence;
|
|
94
|
+
if (!fence && !marker && start >= 0 && breaks.has(at)) {
|
|
95
|
+
blocks.push({ start, end: at - 1, fence: fenced });
|
|
96
|
+
start = -1;
|
|
97
|
+
fenced = false;
|
|
98
|
+
}
|
|
51
99
|
if (blank && !fence && !marker) {
|
|
52
100
|
if (start >= 0)
|
|
53
101
|
blocks.push({ start, end: at - 1, fence: fenced });
|
|
@@ -64,14 +112,17 @@ export function passages(markdown, maxChars = 1200) {
|
|
|
64
112
|
}
|
|
65
113
|
if (start >= 0)
|
|
66
114
|
blocks.push({ start, end: markdown.length, fence: fenced });
|
|
67
|
-
let heading = '';
|
|
115
|
+
let heading = '', post = '';
|
|
116
|
+
const label = (start) => posts.findLast((p) => p.start <= start)?.label ?? '';
|
|
68
117
|
for (const b of blocks) {
|
|
69
118
|
const raw = markdown.slice(b.start, b.end).trimEnd(), end = b.start + raw.length;
|
|
70
119
|
if (b.fence || !raw.trim())
|
|
71
120
|
continue;
|
|
121
|
+
if (label(b.start) !== post)
|
|
122
|
+
heading = post = label(b.start);
|
|
72
123
|
const h = /^\s{0,3}#{1,6}\s+(.+)$/.exec(raw);
|
|
73
124
|
if (h && !raw.includes('\n')) {
|
|
74
|
-
heading = collapse(h[1]).slice(0, 200);
|
|
125
|
+
heading = (post ? `${post}: ` : '') + collapse(h[1]).slice(0, 200);
|
|
75
126
|
continue;
|
|
76
127
|
}
|
|
77
128
|
if (/^(\s*!\[[^\]]*\]\([^)]*\)\s*)+$/.test(raw))
|
|
@@ -91,6 +142,32 @@ export function passages(markdown, maxChars = 1200) {
|
|
|
91
142
|
}
|
|
92
143
|
return out;
|
|
93
144
|
}
|
|
145
|
+
const frontmatter = (markdown) => /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/u.exec(markdown)?.[0] ?? '';
|
|
146
|
+
/** A `key: "value"` frontmatter field, as captures write them. */
|
|
147
|
+
function captureField(markdown, key) {
|
|
148
|
+
const match = new RegExp(`^${key}: (".*")\\r?$`, 'm').exec(frontmatter(markdown));
|
|
149
|
+
try {
|
|
150
|
+
const value = match ? JSON.parse(match[1]) : undefined;
|
|
151
|
+
return typeof value === 'string' ? value : undefined;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Prose words under which a capture holds nothing to digest: a gallery of numbered images, a bare link list. */
|
|
158
|
+
const minProseWords = 20;
|
|
159
|
+
/**
|
|
160
|
+
* Why a capture cannot be digested, as a detail code, or undefined when it can: its body (past the frontmatter, images
|
|
161
|
+
* dropped, link text kept) has under `minProseWords` words with letters, or only an archive's metadata was saved.
|
|
162
|
+
*/
|
|
163
|
+
export function undigestible(markdown) {
|
|
164
|
+
if (captureField(markdown, 'capture_method') === 'archive-metadata')
|
|
165
|
+
return 'source-too-thin';
|
|
166
|
+
const prose = passages(markdown.slice(frontmatter(markdown).length))
|
|
167
|
+
.map((p) => p.text.replace(/(!?)\[([^\]]*)\]\([^)]*\)/g, (_, image, text) => (image ? ' ' : text)))
|
|
168
|
+
.join(' ');
|
|
169
|
+
return (prose.match(/\p{L}[\p{L}\p{M}'’-]*/gu) ?? []).length < minProseWords ? 'source-too-thin' : undefined;
|
|
170
|
+
}
|
|
94
171
|
function lastSentenceEnd(window) {
|
|
95
172
|
let best = -1;
|
|
96
173
|
for (const m of window.matchAll(/[.!?]["')\]]*(?=\s)/g))
|
|
@@ -196,7 +273,12 @@ Source: an essay arguing that taste is a skill.
|
|
|
196
273
|
claim: Volume beats polish early. | detail: Side-by-side comparison of many rough drafts sharpens judgment faster than finishing one.
|
|
197
274
|
Bad claim: The essay explores how practice over time shapes a person's sense of taste.
|
|
198
275
|
Good claim: Taste is trained.`;
|
|
199
|
-
|
|
276
|
+
/** Who speaks in each form, so claims are credited to the person who made them. */
|
|
277
|
+
const voices = {
|
|
278
|
+
thread: 'This source is an X thread. Its replies are left out. When the author quotes or answers someone, that view stays theirs.',
|
|
279
|
+
video: 'This source is a recording: credit each claim to the person who says it. In an interview or podcast, the guest’s views are the guest’s, not the host’s.',
|
|
280
|
+
};
|
|
281
|
+
const system = (budget, form, vocabulary) => `You write a Digest v2 of one source for a private reading library, in the owner's voice: terse, declarative, specific. Reply with JSON only, matching the schema.
|
|
200
282
|
- lead: one sentence of at most ${L.leadChars} characters stating this source's specific thesis or result: who (author, organization or product by name) shows or argues what, with the number, name or mechanism that makes it this source. Do not restate the title, describe the source ("This article discusses"), or tease.
|
|
201
283
|
- context: 1 or 2 sentences, at most 45 words: the evidence, method, numbers or limits the lead could not hold. Lead and context together stay within ${L.gistWords} words and ${L.gistSentences} sentences.
|
|
202
284
|
- ideas: ${L.minIdeas} to ${L.maxIdeas} (prefer ${L.maxIdeas} when the source has them), in the order the author makes them, covering the whole source including its caveats and limits. Each idea is a claim with detail:
|
|
@@ -205,7 +287,12 @@ const system = (budget, vocabulary) => `You write a Digest v2 of one source for
|
|
|
205
287
|
- quotes: 0 to ${L.maxQuotesPerIdea} that support this idea, each a contiguous span copied character for character from one numbered passage, ${minQuoteWords + 2} to 20 words, with that passage number. A quote is a fragment, not a whole long sentence: when a sentence runs past 20 words, copy only its strongest part. No added quotation marks or ellipses. Give attribution (who said it) when it is not the author. Prefer reader highlights.
|
|
206
288
|
- Attach at least ${L.minQuotes} and at most ${L.maxQuotes} quotes in all; ${L.minQuotes + 1} to 3 is typical. All quotes together stay within ${budget} words and never overlap.
|
|
207
289
|
- tags: 0 to ${L.maxTags} ${vocabulary?.length ? `from: ${vocabulary.join(', ')}` : 'lowercase kebab-case topics'}; usually one subject tag.
|
|
208
|
-
|
|
290
|
+
Faithfulness comes before style:
|
|
291
|
+
- Credit each view to who holds it. When the source quotes, interviews or reports someone, their claims are theirs: name them in the detail and the quote attribution, never pass them off as the author's.${voices[form] ? `\n- ${voices[form]}` : ''}
|
|
292
|
+
- Keep each number with its own subject, unit and meaning (an area is not a height, a share is not a count, a cost is not a price) and never compute one the source does not give.
|
|
293
|
+
- Keep the source's direction: never invert a claim, comparison or cause.
|
|
294
|
+
- Keep the source's certainty: a forecast, plan, proposal, rumor or question stays one ("plans to", "expects", "asks whether"); never state it as done or settled. Hedge only where the source does.
|
|
295
|
+
Plain words. Name things and use numbers. No filler: various, significantly, key, crucial, notably, highlights, explores, discusses, "the author argues", "the article".
|
|
209
296
|
Use only what the source says.
|
|
210
297
|
|
|
211
298
|
${styleExamples}`;
|
|
@@ -255,7 +342,9 @@ function fromModel(text, form) {
|
|
|
255
342
|
}
|
|
256
343
|
if (!object(v) || !Array.isArray(v.ideas) || !Array.isArray(v.tags))
|
|
257
344
|
return { issues: [{ path: '', code: 'shape' }] };
|
|
258
|
-
const trim = (s) => (typeof s === 'string' ? s.trim() : s),
|
|
345
|
+
const trim = (s) => (typeof s === 'string' ? s.trim() : s),
|
|
346
|
+
// Models sometimes end a sentence with the passage it came from ("… market. [2]").
|
|
347
|
+
prose = (s) => (typeof s === 'string' ? s.replace(/(\s*\[\d+(?:,\s*\d+)*\])+$/, '').trim() : s), cites = [];
|
|
259
348
|
const ideas = v.ideas.map((idea) => {
|
|
260
349
|
if (!object(idea)) {
|
|
261
350
|
cites.push([]);
|
|
@@ -264,19 +353,20 @@ function fromModel(text, form) {
|
|
|
264
353
|
const quotes = Array.isArray(idea.quotes) ? idea.quotes : [];
|
|
265
354
|
cites.push(quotes.map((q) => (Number.isSafeInteger(q?.passage) ? q.passage : undefined)));
|
|
266
355
|
return {
|
|
267
|
-
claim:
|
|
268
|
-
detail:
|
|
356
|
+
claim: prose(idea.claim),
|
|
357
|
+
detail: prose(idea.detail),
|
|
269
358
|
quotes: quotes.map((q) => {
|
|
270
359
|
const attribution = trim(q?.attribution);
|
|
271
360
|
return { text: trim(q?.text), ...(attribution ? { attribution } : {}) };
|
|
272
361
|
}),
|
|
273
362
|
};
|
|
274
363
|
});
|
|
275
|
-
|
|
364
|
+
// A context the model stopped mid-sentence is dropped; context is optional.
|
|
365
|
+
const context = ((c) => (typeof c === 'string' && !/[.!?…。!?)"”’]$/.test(c) ? undefined : c))(prose(v.context));
|
|
276
366
|
const parsed = parseDigest({
|
|
277
367
|
schemaVersion: 2,
|
|
278
368
|
form,
|
|
279
|
-
gist: { lead:
|
|
369
|
+
gist: { lead: prose(v.lead), ...(context ? { context } : {}) },
|
|
280
370
|
ideas,
|
|
281
371
|
tags: v.tags.map(trim),
|
|
282
372
|
});
|
|
@@ -453,7 +543,10 @@ export async function draftDigest(input) {
|
|
|
453
543
|
input.vocabulary.length > 256 ||
|
|
454
544
|
input.vocabulary.some((t) => !/^[a-z0-9][a-z0-9-]{0,63}$/.test(t))))
|
|
455
545
|
throw new SpongeError('invalid-input');
|
|
456
|
-
const
|
|
546
|
+
const thin = undigestible(input.markdown);
|
|
547
|
+
if (thin)
|
|
548
|
+
throw new SpongeError(thin);
|
|
549
|
+
const inputHash = digestInputHash({ ...input, model: identity }), source = input.markdown, list = input.form === 'thread' ? authorPassages(source) : passages(source);
|
|
457
550
|
if (list.length === 0)
|
|
458
551
|
throw new SpongeError('invalid-input');
|
|
459
552
|
const userSignal = input.signal ?? new AbortController().signal, overall = AbortSignal.any([userSignal, AbortSignal.timeout(p.deadlineMs)]);
|
|
@@ -564,7 +657,7 @@ export async function draftDigest(input) {
|
|
|
564
657
|
}
|
|
565
658
|
const budget = quoteBudget(source), schema = draftSchema(list.length, input.vocabulary);
|
|
566
659
|
const messages = [
|
|
567
|
-
{ role: 'system', content: system(budget, input.vocabulary) },
|
|
660
|
+
{ role: 'system', content: system(budget, input.form, input.vocabulary) },
|
|
568
661
|
{
|
|
569
662
|
role: 'user',
|
|
570
663
|
content: (notesText ? `Notes from each part of the source, in order:\n${notesText}\n\n` : '') +
|
|
@@ -637,9 +730,16 @@ export async function draftDigest(input) {
|
|
|
637
730
|
};
|
|
638
731
|
return { output: result.draft.digest, inputHash, producer, options, trace };
|
|
639
732
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
733
|
+
/** X thread captures draft as `thread`, whose prompt rules keep replies apart from the author's posts. */
|
|
734
|
+
const formFor = (kind, markdown) => kind === 'pdf'
|
|
735
|
+
? 'pdf'
|
|
736
|
+
: kind === 'media'
|
|
737
|
+
? 'video'
|
|
738
|
+
: captureField(markdown, 'platform') === 'x' && captureField(markdown, 'capture_scope') === 'thread'
|
|
739
|
+
? 'thread'
|
|
740
|
+
: 'article';
|
|
741
|
+
/** What the item's current revision is drafted from: its Markdown, title, form and anchored highlights. */
|
|
742
|
+
export function digestInputFor(store, itemId, model, options = {}) {
|
|
643
743
|
const item = store.read(itemId);
|
|
644
744
|
if (!item)
|
|
645
745
|
throw new SpongeError('unavailable');
|
|
@@ -647,21 +747,25 @@ export async function createDigest(store, itemId, model, options = {}) {
|
|
|
647
747
|
.list(itemId)
|
|
648
748
|
.filter((a) => a.kind === 'highlight' && a.revisionId === item.revisionId && a.anchored !== false)
|
|
649
749
|
.map((a) => ({ text: a.anchor.exact, start: a.anchor.start }));
|
|
650
|
-
|
|
750
|
+
return {
|
|
651
751
|
...options,
|
|
652
752
|
revisionId: item.revisionId,
|
|
653
753
|
markdown: item.markdown,
|
|
654
754
|
title: item.title,
|
|
655
|
-
form: formFor(item.kind),
|
|
755
|
+
form: formFor(item.kind, item.markdown),
|
|
656
756
|
model,
|
|
657
757
|
highlights,
|
|
658
758
|
};
|
|
659
|
-
|
|
759
|
+
}
|
|
760
|
+
/** Draft and store a digest for the item's current revision. A matching input hash returns the stored draft without calling the model. */
|
|
761
|
+
export async function createDigest(store, itemId, model, options = {}) {
|
|
762
|
+
const input = digestInputFor(store, itemId, model, options);
|
|
763
|
+
const prior = store.derivations.find('digest', input.revisionId, digestInputHash({ ...input, model: model.identity }));
|
|
660
764
|
if (prior)
|
|
661
765
|
return { derivation: prior, duplicate: true };
|
|
662
766
|
const draft = await draftDigest(input), stored = store.derivations.put('digest', {
|
|
663
767
|
itemId,
|
|
664
|
-
revisionId:
|
|
768
|
+
revisionId: input.revisionId,
|
|
665
769
|
producer: draft.producer,
|
|
666
770
|
inputHash: draft.inputHash,
|
|
667
771
|
output: draft.output,
|
package/dist/src/errors.d.ts
CHANGED
|
@@ -19,8 +19,10 @@ declare const parents: {
|
|
|
19
19
|
readonly 'draft-rejected': "unavailable";
|
|
20
20
|
readonly 'model-unavailable': "unavailable";
|
|
21
21
|
readonly 'model-busy': "unavailable";
|
|
22
|
+
readonly 'source-too-thin': "unavailable";
|
|
22
23
|
readonly 'parent-missing': "invalid-input";
|
|
23
24
|
readonly 'target-exists': "invalid-input";
|
|
25
|
+
readonly 'connection-retired': "unsupported";
|
|
24
26
|
};
|
|
25
27
|
export type DetailCode = keyof typeof parents;
|
|
26
28
|
export type SpongeCode = ErrorCode | DetailCode;
|