@hraness/sponge 0.5.3 → 0.6.2

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 (66) hide show
  1. package/README.md +8 -11
  2. package/bin/sponge.js +4 -0
  3. package/dist/capture/NOTICES.txt +472 -0
  4. package/dist/capture/worker.js +553 -0
  5. package/dist/src/acquisition/capture.d.ts +3 -10
  6. package/dist/src/acquisition/capture.js +28 -202
  7. package/dist/src/acquisition/child-request.d.ts +40 -0
  8. package/dist/src/acquisition/child.d.ts +31 -0
  9. package/dist/src/acquisition/child.js +130 -0
  10. package/dist/src/acquisition/errors.d.ts +2 -2
  11. package/dist/src/acquisition/errors.js +15 -4
  12. package/dist/src/acquisition/index.d.ts +0 -3
  13. package/dist/src/acquisition/index.js +0 -2
  14. package/dist/src/acquisition/normalize.d.ts +1 -3
  15. package/dist/src/acquisition/normalize.js +20 -22
  16. package/dist/src/app/article.d.ts +1 -2
  17. package/dist/src/app/article.js +1 -2
  18. package/dist/src/cli/commands.d.ts +0 -1
  19. package/dist/src/cli/commands.js +8 -35
  20. package/dist/src/cli/import.d.ts +1 -3
  21. package/dist/src/cli/import.js +3 -3
  22. package/dist/src/cli/main.js +0 -2
  23. package/dist/src/cli/runtimes.js +2 -3
  24. package/dist/src/cli/worker.d.ts +1 -4
  25. package/dist/src/cli/worker.js +0 -4
  26. package/dist/src/importers/apply.d.ts +0 -2
  27. package/dist/src/importers/apply.js +4 -3
  28. package/dist/src/importers/inspect.d.ts +2 -2
  29. package/dist/src/importers/inspect.js +24 -23
  30. package/dist/src/metadata/article.d.ts +0 -1
  31. package/dist/src/metadata/article.js +3 -2
  32. package/dist/src/pdf/capture.d.ts +7 -16
  33. package/dist/src/pdf/capture.js +44 -198
  34. package/dist/src/pdf/handler.d.ts +1 -2
  35. package/dist/src/pdf/handler.js +2 -5
  36. package/dist/src/pdf/normalize.d.ts +2 -3
  37. package/dist/src/pdf/normalize.js +34 -38
  38. package/dist/src/pdf/title.d.ts +3 -0
  39. package/dist/src/pdf/title.js +19 -0
  40. package/dist/src/pdf/tools.d.ts +2 -0
  41. package/dist/src/pdf/tools.js +21 -0
  42. package/dist/src/search/hybrid.js +18 -13
  43. package/dist/src/search/lexical.d.ts +10 -1
  44. package/dist/src/search/lexical.js +34 -5
  45. package/native/runtime-manifest.json +5 -5
  46. package/package.json +11 -2
  47. package/dist/src/acquisition/clip-helper.js +0 -14
  48. package/dist/src/acquisition/lock.d.ts +0 -1
  49. package/dist/src/acquisition/lock.js +0 -2
  50. package/dist/src/acquisition/pins.d.ts +0 -3
  51. package/dist/src/acquisition/pins.js +0 -21
  52. package/dist/src/acquisition/runtime.d.ts +0 -48
  53. package/dist/src/acquisition/runtime.js +0 -273
  54. package/dist/src/acquisition/verify-helper.d.ts +0 -1
  55. package/dist/src/acquisition/verify-helper.js +0 -26
  56. package/dist/src/importers/verify-helper.d.ts +0 -1
  57. package/dist/src/importers/verify-helper.js +0 -41
  58. package/dist/src/pdf/capture-helper.d.ts +0 -1
  59. package/dist/src/pdf/capture-helper.js +0 -15
  60. package/dist/src/pdf/errors.d.ts +0 -2
  61. package/dist/src/pdf/errors.js +0 -23
  62. package/dist/src/pdf/source-identity.d.ts +0 -4
  63. package/dist/src/pdf/source-identity.js +0 -13
  64. package/dist/src/pdf/verify-helper.d.ts +0 -1
  65. package/dist/src/pdf/verify-helper.js +0 -29
  66. /package/dist/src/acquisition/{clip-helper.d.ts → child-request.js} +0 -0
@@ -1,212 +1,58 @@
1
- import { mkdir, mkdtemp, writeFile, statfs, realpath } from 'node:fs/promises';
2
1
  import { join } from 'node:path';
3
2
  import { fileURLToPath } from 'node:url';
4
3
  import { limits } from '../../contracts/index.js';
5
- import { privateRoot, physicalBytes } from '../acquisition/files.js';
6
- import { runOwned, isolatedEnvironment } from '../acquisition/process.js';
7
- import { inspectCaptureRuntime } from '../acquisition/runtime.js';
8
- import { settleCaptureRoot } from '../acquisition/cleanup.js';
4
+ import { superviseChild } from '../acquisition/child.js';
9
5
  import { failure } from '../acquisition/errors.js';
10
- import { object } from '../acquisition/normalize.js';
11
6
  import { normalizePdfBundle } from './normalize.js';
12
7
  import { copyPdfInput } from './input.js';
13
- import { pdfFailure } from './errors.js';
14
- import { SpongeError, publicCode, codeOf } from '../errors.js';
15
- export async function capturePdf(request) {
16
- const { requestId, signal } = request;
17
- if (!/^[a-zA-Z0-9_-]{1,128}$/.test(requestId))
18
- return failure('invalid-request', 'invalid-input');
19
- let url;
20
- try {
21
- url = new URL(request.sourceUrl);
22
- if (!['http:', 'https:', 'file:'].includes(url.protocol) ||
23
- url.username ||
24
- url.password ||
25
- request.sourceUrl.length > 16384)
26
- throw Error();
27
- }
28
- catch {
29
- return failure(requestId, 'invalid-input');
30
- }
31
- if (signal?.aborted)
32
- return failure(requestId, 'cancelled');
33
- let runtime;
8
+ function sourceOf(sourceUrl) {
34
9
  try {
35
- runtime = await inspectCaptureRuntime(request.runtime.root, request.runtime.pins);
36
- }
37
- catch (error) {
38
- return failure(requestId, publicCode(error, 'runtime-missing'), 'setup');
39
- }
40
- let base;
41
- try {
42
- base = await privateRoot(request.stagingRoot);
43
- const disk = await statfs(base);
44
- if (disk.bavail * disk.bsize < limits.stagingBytes + limits.freeReserveBytes)
45
- return failure(requestId, 'limit-exceeded');
10
+ const url = new URL(sourceUrl);
11
+ if (!['http:', 'https:', 'file:'].includes(url.protocol) || url.username || url.password)
12
+ return;
13
+ if (sourceUrl.length > 16384 || (url.protocol === 'file:' && (url.host || url.search || url.hash)))
14
+ return;
15
+ return url;
46
16
  }
47
17
  catch {
48
- return failure(requestId, 'invalid-input');
18
+ return;
49
19
  }
50
- const deadline = request.deadlineMs ?? limits.captureDeadlineMs;
51
- if (!Number.isSafeInteger(deadline) || deadline < 100 || deadline > limits.captureDeadlineMs)
52
- return failure(requestId, 'invalid-input');
53
- if (url.protocol === 'file:' && (url.host || url.search || url.hash || request.connection))
54
- return failure(requestId, 'invalid-input');
55
- if (request.connection || request.allowPrivateNetwork)
56
- return failure(requestId, 'unsupported');
57
- const captureHelper = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './capture-helper.ts' : './capture-helper.js', import.meta.url));
58
- const args = [
59
- captureHelper,
60
- runtime.root,
61
- request.sourceUrl,
62
- '--slug',
63
- 'document',
64
- '--timeout-ms',
65
- String(deadline),
66
- '--max-pdf-bytes',
67
- String(limits.pdfBytes),
68
- '--max-pages',
69
- '500',
70
- '--max-images',
71
- String(limits.imagesPerItem),
72
- '--max-asset-bytes',
73
- String(limits.imageBytes),
74
- '--max-total-asset-bytes',
75
- String(500 * 1024 ** 2),
76
- '--quiet',
77
- '--json',
78
- ];
79
- const root = await realpath(await mkdtemp(join(base, 'capture-'))), home = join(root, 'home'), output = join(root, 'bundles');
80
- args.push('--output', output);
81
- const env = isolatedEnvironment(home, home);
82
- const controller = new AbortController();
83
- const abort = () => controller.abort();
84
- signal?.addEventListener('abort', abort, { once: true });
85
- if (signal?.aborted)
86
- abort();
87
- let cleanup = 'settled', keep = false, quota = false, checking = false;
88
- const monitor = setInterval(async () => {
89
- if (checking)
90
- return;
91
- checking = true;
92
- try {
93
- if ((await physicalBytes(root)) > limits.stagingBytes) {
94
- quota = true;
95
- controller.abort();
20
+ }
21
+ export async function capturePdf(request) {
22
+ const url = sourceOf(request.sourceUrl);
23
+ // A PDF is fetched directly; a signed-in browser adds nothing.
24
+ if (url && request.connection)
25
+ return failure(request.requestId, 'unsupported');
26
+ let sourceHash;
27
+ return superviseChild({
28
+ ...request,
29
+ valid: url !== undefined,
30
+ prepare: async ({ root, output, deadline, signal }) => {
31
+ let source = { url: request.sourceUrl };
32
+ if (url.protocol === 'file:') {
33
+ const path = join(root, 'input.pdf');
34
+ sourceHash = await copyPdfInput(fileURLToPath(url), path, signal);
35
+ source = { path };
96
36
  }
97
- }
98
- catch {
99
- quota = true;
100
- controller.abort();
101
- }
102
- finally {
103
- checking = false;
104
- }
105
- }, 100);
106
- const started = Date.now();
107
- try {
108
- await mkdir(home, { mode: 0o700 });
109
- await mkdir(output, { mode: 0o700 });
110
- await writeFile(join(root, 'ownership.json'), JSON.stringify({ schemaVersion: 1, requestId, state: 'acquiring' }), {
111
- mode: 0o600,
112
- });
113
- let sourceHash;
114
- if (url.protocol === 'file:') {
115
- sourceHash = await copyPdfInput(fileURLToPath(url), join(root, 'input.pdf'), controller.signal);
116
- args[2] = join(root, 'input.pdf');
117
- }
118
- cleanup = 'pending';
119
- const result = await (request.runProcess ?? runOwned)(runtime.bun, args, {
120
- cwd: root,
121
- env,
122
- signal: controller.signal,
123
- deadlineMs: Math.max(1, deadline - (Date.now() - started)),
124
- maxOutputBytes: limits.metadataBytes,
125
- graceMs: 5000,
126
- });
127
- cleanup = result.cleanup;
128
- let summary;
129
- try {
130
- summary = JSON.parse(result.stdout);
131
- }
132
- catch {
133
- summary = null;
134
- }
135
- const upstream = pdfFailure(summary, requestId);
136
- if (upstream.code === 'cleanup-required')
137
- cleanup = 'pending';
138
- if (cleanup !== 'settled')
139
- return failure(requestId, 'cleanup-required', 'cleanup');
140
- if (quota)
141
- return failure(requestId, 'limit-exceeded');
142
- if (result.stopped)
143
- return failure(requestId, result.stopped);
144
- if (result.code !== 0 || object(summary).ok !== true)
145
- return upstream;
146
- const outputDirectory = object(summary).outputDirectory;
147
- if (typeof outputDirectory !== 'string' ||
148
- !outputDirectory.startsWith(output + '/') ||
149
- (await realpath(outputDirectory)) !== outputDirectory)
150
- return failure(requestId, 'integrity', 'verification');
151
- const helper = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './verify-helper.ts' : './verify-helper.js', import.meta.url));
152
- cleanup = 'pending';
153
- const checked = await (request.runProcess ?? runOwned)(runtime.bun, [helper, runtime.root, outputDirectory, request.sourceUrl], {
154
- cwd: root,
155
- env,
156
- signal: controller.signal,
157
- deadlineMs: Math.max(1, deadline - (Date.now() - started)),
158
- maxOutputBytes: limits.helperFrameBytes,
159
- });
160
- cleanup = checked.cleanup;
161
- if (cleanup !== 'settled')
162
- return failure(requestId, 'cleanup-required', 'cleanup');
163
- if (checked.stopped)
164
- return failure(requestId, checked.stopped);
165
- if (checked.code !== 0)
166
- return failure(requestId, 'integrity', 'verification');
167
- const capture = await normalizePdfBundle({
168
- bundleRoot: outputDirectory,
169
- inspection: JSON.parse(checked.stdout),
170
- summary,
171
- requestId,
37
+ return {
38
+ schemaVersion: 1,
39
+ kind: 'pdf',
40
+ source,
41
+ outputDirectory: output,
42
+ timeoutMs: deadline,
43
+ maxPdfBytes: limits.pdfBytes,
44
+ maxPages: 500,
45
+ maxImages: limits.imagesPerItem,
46
+ maxAssetBytes: limits.imageBytes,
47
+ maxTotalAssetBytes: 500 * 1024 ** 2,
48
+ allowPrivateNetwork: request.allowPrivateNetwork === true,
49
+ };
50
+ },
51
+ verify: (bundleRoot) => normalizePdfBundle({
52
+ bundleRoot,
53
+ requestId: request.requestId,
172
54
  sourceUrl: request.sourceUrl,
173
- wordcellVersion: runtime.pins.wordcell.version,
174
55
  ...(sourceHash ? { sourceHash } : {}),
175
- });
176
- if (controller.signal.aborted)
177
- return failure(requestId, quota ? 'limit-exceeded' : 'cancelled');
178
- await writeFile(join(root, 'ownership.json'), JSON.stringify({ schemaVersion: 1, requestId, state: 'verified' }), {
179
- mode: 0o600,
180
- });
181
- keep = true;
182
- return {
183
- ok: true,
184
- capture,
185
- bundleRoot: outputDirectory,
186
- dispose: async () => {
187
- if ((await settleCaptureRoot(root, requestId)) === 'pending')
188
- throw new SpongeError('cleanup-required');
189
- },
190
- };
191
- }
192
- catch (error) {
193
- if (cleanup !== 'settled')
194
- return failure(requestId, 'cleanup-required', 'cleanup');
195
- const raw = codeOf(error), code = raw === 'integrity' ||
196
- raw === 'limit-exceeded' ||
197
- raw === 'schema-newer' ||
198
- raw === 'invalid-input' ||
199
- raw === 'cancelled'
200
- ? raw
201
- : 'unclassified';
202
- return failure(requestId, code, 'verification');
203
- }
204
- finally {
205
- clearInterval(monitor);
206
- while (checking)
207
- await new Promise((r) => setTimeout(r, 5));
208
- signal?.removeEventListener('abort', abort);
209
- if (!keep && cleanup === 'settled' && (await settleCaptureRoot(root, requestId)) === 'pending')
210
- return failure(requestId, 'cleanup-required', 'cleanup');
211
- }
56
+ }),
57
+ });
212
58
  }
@@ -1,5 +1,5 @@
1
1
  import type { ErrorEnvelope, JobCheckpoint } from '../../contracts/index.js';
2
- import { type CaptureRuntime, type CaptureConnection } from '../acquisition/index.js';
2
+ import { type CaptureConnection } from '../acquisition/index.js';
3
3
  import { type PdfCaptureRequest as CaptureRequest, type CaptureOutcome } from './capture.js';
4
4
  import type { LibraryStore, AttemptToken } from '../store/index.js';
5
5
  interface PdfResult {
@@ -14,7 +14,6 @@ export type PdfOutcome = (PdfResult | ErrorEnvelope) & {
14
14
  export interface PdfOptions {
15
15
  checkpoint?: (stage: JobCheckpoint['stage']) => Promise<void>;
16
16
  runProcess?: CaptureRequest['runProcess'];
17
- runtime: CaptureRuntime;
18
17
  connection?: CaptureConnection;
19
18
  signal?: AbortSignal;
20
19
  allowPrivateNetwork?: boolean;
@@ -4,9 +4,8 @@ import { capturePdf } from './capture.js';
4
4
  import { runAttempt, classifyErrors } from '../ingest/attempt.js';
5
5
  import { sha256 } from '../util/hash.js';
6
6
  import { bundleBytes, bundleText } from '../app/bundle-files.js';
7
- import { selectedMetadata } from '../metadata/article.js';
8
7
  import { localizeMarkdown } from '../app/markdown.js';
9
- import { pdfHeadingTitle } from './title.js';
8
+ import { pdfTitle } from './title.js';
10
9
  import { SpongeError } from '../errors.js';
11
10
  const classify = classifyErrors([
12
11
  'integrity',
@@ -24,7 +23,6 @@ export function ingestPdf(store, token, options) {
24
23
  throw new SpongeError('invalid-input');
25
24
  const result = await (options.capture ?? capturePdf)({
26
25
  runProcess: options.runProcess,
27
- runtime: options.runtime,
28
26
  stagingRoot: join(store.root, 'staging'),
29
27
  sourceUrl: source.locator,
30
28
  requestId: token.attemptId,
@@ -61,8 +59,7 @@ export function ingestPdf(store, token, options) {
61
59
  store.queue.cancel(token.jobId);
62
60
  throw new SpongeError('cancelled');
63
61
  }
64
- const selectedTitle = selectedMetadata(capture.metadata, 'title')?.value;
65
- const title = typeof selectedTitle === 'string' ? selectedTitle : pdfHeadingTitle(original);
62
+ const title = pdfTitle(capture.metadata, original);
66
63
  const status = localized.missingImages ? 'partial' : capture.status;
67
64
  // Representation algorithm participates in identity: later extraction changes cannot reuse an old payload.
68
65
  const bundleHash = sha256(JSON.stringify(['pdf-local-markdown-v1', capture.manifestSha256]));
@@ -1,10 +1,9 @@
1
1
  import type { CaptureResult } from '../../contracts/index.js';
2
+ /** Re-verify a PDF bundle written by the owned child: every declared file is re-read and rehashed here. */
2
3
  export declare function normalizePdfBundle(input: {
3
4
  bundleRoot: string;
4
- inspection: unknown;
5
- summary: unknown;
6
5
  requestId: string;
7
6
  sourceUrl: string;
8
- wordcellVersion: string;
7
+ /** The hash of the parent's own copy of a local input. */
9
8
  sourceHash?: string;
10
9
  }): Promise<CaptureResult>;
@@ -4,16 +4,16 @@ import { readConfined } from '../acquisition/files.js';
4
4
  import { sha256 as hash } from '../util/hash.js';
5
5
  import { object } from '../acquisition/normalize.js';
6
6
  import { SpongeError } from '../errors.js';
7
+ /** Identifies Sponge's own PDF representation; bump when the bundle meaning changes. */
8
+ const pdfExtractor = { name: 'sponge', version: 'pdf-v1' }, rule = 'sponge-pdf-v1';
9
+ /** Re-verify a PDF bundle written by the owned child: every declared file is re-read and rehashed here. */
7
10
  export async function normalizePdfBundle(input) {
8
- const verified = object(input.inspection), inspection = object(verified.inspection), summary = object(input.summary);
9
- if (verified.ok !== true || inspection.schemaVersion !== 1 || !Array.isArray(inspection.assets))
10
- throw new SpongeError('integrity');
11
11
  const manifestBytes = await readConfined(input.bundleRoot, 'capture.json', limits.metadataBytes), manifest = object(JSON.parse(manifestBytes.toString('utf8')));
12
12
  if (manifest.schemaVersion !== 1 ||
13
13
  manifest.kind !== 'pdf' ||
14
14
  !['complete', 'partial'].includes(String(manifest.status)))
15
15
  throw new SpongeError('integrity');
16
- function asset(raw, mediaType) {
16
+ async function verified(raw, mediaType, maxBytes) {
17
17
  const value = object(raw);
18
18
  const result = {
19
19
  relativePath: String(value.path),
@@ -22,55 +22,51 @@ export async function normalizePdfBundle(input) {
22
22
  mediaType,
23
23
  };
24
24
  validateAsset(result);
25
+ if (result.bytes > maxBytes)
26
+ throw new SpongeError('limit-exceeded');
27
+ const content = await readConfined(input.bundleRoot, result.relativePath, maxBytes);
28
+ if (content.length !== result.bytes || hash(content) !== result.sha256)
29
+ throw new SpongeError('integrity');
25
30
  return result;
26
31
  }
27
- function assertVerified(value, checked) {
28
- const record = object(checked);
29
- if (record.integrity !== 'verified' ||
30
- record.path !== value.relativePath ||
31
- record.sha256 !== value.sha256 ||
32
- record.bytes !== value.bytes)
32
+ const markdown = await verified(manifest.markdown, 'text/markdown', limits.markdownBytes), sourcePdf = await verified(manifest.source, 'application/pdf', limits.pdfBytes);
33
+ if (input.sourceHash !== undefined) {
34
+ if (sourcePdf.sha256 !== input.sourceHash)
35
+ throw new SpongeError('integrity');
36
+ }
37
+ else {
38
+ // The child records the requested URL with secrets redacted; its origin and path must still be ours.
39
+ const requested = new URL(String(object(manifest.source).requestedUrl)), expected = new URL(input.sourceUrl);
40
+ if (requested.origin !== expected.origin || requested.pathname !== expected.pathname)
33
41
  throw new SpongeError('integrity');
34
42
  }
35
- const markdown = asset(manifest.markdown, 'text/markdown'), sourcePdf = asset(manifest.source, 'application/pdf');
36
- assertVerified(markdown, inspection.markdown);
37
- assertVerified(sourcePdf, inspection.source);
38
- if (input.sourceHash && sourcePdf.sha256 !== input.sourceHash)
39
- throw new SpongeError('integrity');
40
- const source = object(manifest.source);
41
- if (!input.sourceHash &&
42
- (typeof verified.expectedSourceUrl !== 'string' || source.requestedUrl !== verified.expectedSourceUrl))
43
- throw new SpongeError('integrity');
44
- const markdownBytes = await readConfined(input.bundleRoot, markdown.relativePath, limits.markdownBytes);
45
- if (hash(markdownBytes) !== markdown.sha256 || markdownBytes.length !== markdown.bytes)
46
- throw new SpongeError('integrity');
47
43
  if (!Array.isArray(manifest.images) || manifest.images.length > limits.imagesPerItem)
48
44
  throw new SpongeError('limit-exceeded');
49
45
  const byPath = new Map();
50
46
  for (const raw of manifest.images) {
51
- const image = object(raw), record = object(image.asset), value = asset(record, String(record.mimeType));
52
- if (!value.mediaType.startsWith('image/'))
53
- throw new SpongeError('integrity');
54
- assertVerified(value, inspection.assets.find((raw) => object(raw).path === value.relativePath));
55
- const prior = byPath.get(value.relativePath);
56
- if (prior && JSON.stringify(prior) !== JSON.stringify(value))
47
+ const record = object(object(raw).asset), mediaType = String(record.mimeType), path = String(record.path);
48
+ if (!mediaType.startsWith('image/') || !path.startsWith('assets/'))
57
49
  throw new SpongeError('integrity');
58
- byPath.set(value.relativePath, value);
50
+ const prior = byPath.get(path);
51
+ if (prior) {
52
+ if (prior.sha256 !== record.sha256 || prior.bytes !== record.bytes || prior.mediaType !== mediaType)
53
+ throw new SpongeError('integrity');
54
+ continue;
55
+ }
56
+ byPath.set(path, await verified(record, mediaType, limits.imageBytes));
59
57
  }
60
- if (byPath.size !== inspection.assets.length)
61
- throw new SpongeError('integrity');
62
58
  const metadata = [], document = object(manifest.document), observedAt = String(manifest.capturedAt);
63
59
  for (const field of ['title', 'author', 'subject', 'creator', 'producer', 'createdAt', 'modifiedAt']) {
64
60
  const value = document[field];
65
61
  if (typeof value === 'string' && value.length > 0 && value.length <= 16384)
66
- metadata.push({ field, value, source: 'pdf', observedAt, rule: 'wordcell-pdf-v1', selected: true });
62
+ metadata.push({ field, value, source: 'pdf', observedAt, rule, selected: true });
67
63
  }
68
64
  metadata.push({
69
65
  field: 'pageCount',
70
66
  value: String(document.pageCount),
71
67
  source: 'pdf',
72
68
  observedAt,
73
- rule: 'wordcell-pdf-v1',
69
+ rule,
74
70
  });
75
71
  if (manifest.quality) {
76
72
  const quality = object(manifest.quality);
@@ -80,7 +76,7 @@ export async function normalizePdfBundle(input) {
80
76
  value: String(quality.issues.length),
81
77
  source: 'pdf',
82
78
  observedAt,
83
- rule: 'wordcell-pdf-v1',
79
+ rule,
84
80
  });
85
81
  if (quality.issues.length > 400)
86
82
  metadata.push({
@@ -88,7 +84,7 @@ export async function normalizePdfBundle(input) {
88
84
  value: 'true',
89
85
  source: 'pdf',
90
86
  observedAt,
91
- rule: 'wordcell-pdf-v1',
87
+ rule,
92
88
  });
93
89
  for (const issue of quality.issues.slice(0, 400)) {
94
90
  const row = object(issue);
@@ -97,12 +93,12 @@ export async function normalizePdfBundle(input) {
97
93
  value: JSON.stringify({ code: row.code, page: row.page }),
98
94
  source: 'pdf',
99
95
  observedAt,
100
- rule: 'wordcell-pdf-v1',
96
+ rule,
101
97
  });
102
98
  }
103
99
  }
104
100
  }
105
- const partial = manifest.status !== 'complete' || inspection.status !== 'complete' || summary.status !== 'complete';
101
+ const partial = manifest.status !== 'complete';
106
102
  const result = {
107
103
  schemaVersion: 1,
108
104
  requestId: input.requestId,
@@ -114,7 +110,7 @@ export async function normalizePdfBundle(input) {
114
110
  assets: [...byPath.values()],
115
111
  metadata,
116
112
  warnings: partial ? ['partial'] : [],
117
- extractor: { name: 'wordcell', version: input.wordcellVersion },
113
+ extractor: { ...pdfExtractor },
118
114
  manifestSha256: hash(manifestBytes),
119
115
  completeness: { text: partial ? 'partial' : 'complete', images: partial ? 'partial' : 'complete' },
120
116
  };
@@ -1,5 +1,8 @@
1
+ import type { MetadataClaim } from '../../contracts/index.js';
1
2
  /**
2
3
  * The first heading Wordcell detected on page 1, for a PDF whose metadata has no title.
3
4
  * Wordcell writes body headings at level 2 or deeper and escapes Markdown punctuation in them.
4
5
  */
5
6
  export declare function pdfHeadingTitle(markdown: string): string | undefined;
7
+ /** A PDF's captured title: its metadata title, else the first page-1 heading, else the file name its bundle records. */
8
+ export declare function pdfTitle(metadata: readonly MetadataClaim[], markdown: string): string | undefined;
@@ -1,3 +1,4 @@
1
+ import { selectedMetadata } from '../metadata/article.js';
1
2
  // Section names that open many papers but never name one.
2
3
  const generic = /^(?:abstract|contents|table of contents|introduction|preface|foreword|acknowledge?ments?|summary|executive summary|references)$/i;
3
4
  /**
@@ -26,3 +27,21 @@ export function pdfHeadingTitle(markdown) {
26
27
  return title;
27
28
  }
28
29
  }
30
+ /** The bundle's own frontmatter title: Sponge and Wordcell both write the source file name there. */
31
+ function frontmatterTitle(markdown) {
32
+ const quoted = /^---\n(?:.*\n)*?title: (".*")\n(?:.*\n)*?---\n/.exec(markdown)?.[1];
33
+ try {
34
+ const title = quoted === undefined ? undefined : JSON.parse(quoted);
35
+ return typeof title === 'string' && title.trim() ? title.trim() : undefined;
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ /** A PDF's captured title: its metadata title, else the first page-1 heading, else the file name its bundle records. */
42
+ export function pdfTitle(metadata, markdown) {
43
+ const selected = selectedMetadata(metadata, 'title')?.value;
44
+ if (typeof selected === 'string' && selected.trim())
45
+ return selected;
46
+ return pdfHeadingTitle(markdown) ?? frontmatterTitle(markdown);
47
+ }
@@ -0,0 +1,2 @@
1
+ /** Whether the Poppler converters PDF capture runs are installed. */
2
+ export declare function pdfToolsAvailable(): boolean;
@@ -0,0 +1,21 @@
1
+ import { accessSync, constants } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ // The child finds the same tools: on PATH, then in the usual package-manager directories.
4
+ const directories = () => [
5
+ ...(process.env.PATH ?? '').split(':').filter((d) => d.startsWith('/')),
6
+ '/opt/homebrew/bin',
7
+ '/usr/local/bin',
8
+ '/usr/bin',
9
+ ];
10
+ /** Whether the Poppler converters PDF capture runs are installed. */
11
+ export function pdfToolsAvailable() {
12
+ return ['pdfinfo', 'pdftohtml'].every((tool) => directories().some((directory) => {
13
+ try {
14
+ accessSync(join(directory, tool), constants.X_OK);
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }));
21
+ }
@@ -1,14 +1,14 @@
1
1
  import { chunkDocument } from '../indexing/index.js';
2
- import { lexicalSearch } from './lexical.js';
2
+ import { anyWordMatch, lexicalChannel, phraseMatch } from './lexical.js';
3
3
  import { pinSearchGeneration, vectorSearch } from './vector.js';
4
4
  import { SpongeError } from '../errors.js';
5
- /** Reciprocal rank fusion after per-channel item collapsing; chunks cannot amplify rank. */
6
- function fuse(lexicalHits, semanticHits, limit) {
5
+ /**
6
+ * Weighted reciprocal rank fusion after per-channel item collapsing; chunks cannot amplify rank. The any-word channel
7
+ * counts a quarter: at full weight its loose matches outranked exact phrases and close meanings (scripts/eval/search.ts).
8
+ */
9
+ function fuse(channels, limit) {
7
10
  const hits = new Map();
8
- for (const [method, rows] of [
9
- ['lexical', lexicalHits],
10
- ['semantic', semanticHits],
11
- ]) {
11
+ for (const { method, rows, weight } of channels) {
12
12
  const seen = new Set();
13
13
  let rank = 0;
14
14
  for (const row of rows) {
@@ -26,8 +26,9 @@ function fuse(lexicalHits, semanticHits, limit) {
26
26
  }
27
27
  : { kind: 'document' };
28
28
  if (previous) {
29
- previous.score += 1 / (60 + rank);
30
- previous.methods.push(method);
29
+ previous.score += weight / (60 + rank);
30
+ if (!previous.methods.includes(method))
31
+ previous.methods.push(method);
31
32
  if (vector)
32
33
  previous.anchor = anchor;
33
34
  }
@@ -36,7 +37,7 @@ function fuse(lexicalHits, semanticHits, limit) {
36
37
  itemId: row.itemId,
37
38
  revisionId: row.revisionId,
38
39
  title: row.title,
39
- score: 1 / (60 + rank),
40
+ score: weight / (60 + rank),
40
41
  methods: [method],
41
42
  anchor,
42
43
  });
@@ -84,7 +85,7 @@ function projectAnchors(db, hits) {
84
85
  }
85
86
  }
86
87
  export async function hybridSearch(db, query, options = {}) {
87
- const limit = options.limit ?? 20, timeout = options.semanticTimeoutMs ?? 10000;
88
+ const limit = options.limit ?? 20, depth = Math.min(500, limit * 5), timeout = options.semanticTimeoutMs ?? 10000;
88
89
  if (!query.trim() ||
89
90
  Buffer.byteLength(query) > 4096 ||
90
91
  !Number.isSafeInteger(limit) ||
@@ -130,14 +131,18 @@ export async function hybridSearch(db, query, options = {}) {
130
131
  let semantic = [];
131
132
  if (pin && encoded) {
132
133
  try {
133
- semantic = vectorSearch(db, pin, encoded, Math.min(500, limit * 5));
134
+ semantic = vectorSearch(db, pin, encoded, depth);
134
135
  }
135
136
  catch {
136
137
  warnings.push('semantic-unavailable');
137
138
  }
138
139
  }
139
140
  const result = {
140
- hits: fuse(lexicalSearch(db, query, Math.min(500, limit * 5)), semantic, limit),
141
+ hits: fuse([
142
+ { method: 'lexical', rows: lexicalChannel(db, phraseMatch(query), depth), weight: 1 },
143
+ { method: 'lexical', rows: lexicalChannel(db, anyWordMatch(query), depth), weight: 0.25 },
144
+ { method: 'semantic', rows: semantic, weight: 1 },
145
+ ], limit),
141
146
  warnings,
142
147
  generation: pin,
143
148
  };
@@ -7,5 +7,14 @@ export interface LexicalHit {
7
7
  /** Contentless FTS rows are keyed by readable_fts_rows.id; items.rowid is not VACUUM-stable. Callers own the transaction. */
8
8
  export declare function indexReadable(db: DatabaseSync, itemId: string, revisionId: string, title: string, markdown: string): void;
9
9
  export declare function unindexReadable(db: DatabaseSync, itemId: string): void;
10
- /** Literal phrase, bm25 rank then item id; excludes tombstones and superseded revisions. */
10
+ /** The query as one literal phrase; surrounding double quotes are the user's way of asking for exactly that. */
11
+ export declare function phraseMatch(query: string): string;
12
+ /** Any content word of the query; undefined for a quoted query or one of function words only. */
13
+ export declare function anyWordMatch(query: string): string | undefined;
14
+ /** bm25 with title matches weighted five times body, then item id; excludes tombstones and superseded revisions. */
15
+ export declare function lexicalChannel(db: DatabaseSync, expression: string | undefined, limit: number): LexicalHit[];
16
+ /**
17
+ * Items with the exact phrase first, then items with any of its content words. The phrase alone missed most vague
18
+ * queries, and any word alone buried the precise matches (scripts/eval/search.ts).
19
+ */
11
20
  export declare function lexicalSearch(db: DatabaseSync, query: string, limit: number): LexicalHit[];