@hraness/sponge 0.6.1 → 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.
package/bin/sponge.js CHANGED
@@ -5,6 +5,10 @@ if (major < 24 || (major === 24 && minor < 20)) {
5
5
  process.stderr.write('Sponge requires Node.js 24.20 or newer.\n');
6
6
  process.exitCode = 2;
7
7
  } else {
8
+ // A reader that closes early (`sponge show ITEM | head`) ends the output, not the command: it still finishes and closes the library.
9
+ process.stdout.on('error', (error) => {
10
+ if (error.code !== 'EPIPE') throw error;
11
+ });
8
12
  const { version } = createRequire(import.meta.url)('../package.json');
9
13
  const { main } = await import('../dist/src/cli/main.js');
10
14
  process.exitCode = await main(process.argv.slice(2), version);
@@ -3,6 +3,7 @@ import { runAttempt } from '../ingest/attempt.js';
3
3
  import { bundleBytes, bundleText } from '../app/bundle-files.js';
4
4
  import { localizeMarkdown } from '../app/markdown.js';
5
5
  import { selectedMetadata } from '../metadata/article.js';
6
+ import { pdfTitle } from '../pdf/title.js';
6
7
  import { drainQueue } from '../worker/engine.js';
7
8
  import { readImportManifest, importBundleRoot } from './manifest.js';
8
9
  import { inspectImportBundle, ownedBundleVerifier } from './inspect.js';
@@ -40,7 +41,7 @@ function promote(context, bundle, namespace, entry, sourceHash) {
40
41
  if (signal.aborted)
41
42
  throw new SpongeError('cancelled');
42
43
  await checkpoint('promoting');
43
- const title = selectedMetadata(bundle.metadata, 'title')?.value;
44
+ const title = bundle.kind === 'pdf' ? pdfTitle(bundle.metadata, markdown) : selectedMetadata(bundle.metadata, 'title')?.value;
44
45
  store.revisions.commit({
45
46
  ...attempt,
46
47
  itemId: source.itemId,
@@ -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',
@@ -60,8 +59,7 @@ export function ingestPdf(store, token, options) {
60
59
  store.queue.cancel(token.jobId);
61
60
  throw new SpongeError('cancelled');
62
61
  }
63
- const selectedTitle = selectedMetadata(capture.metadata, 'title')?.value;
64
- const title = typeof selectedTitle === 'string' ? selectedTitle : pdfHeadingTitle(original);
62
+ const title = pdfTitle(capture.metadata, original);
65
63
  const status = localized.missingImages ? 'partial' : capture.status;
66
64
  // Representation algorithm participates in identity: later extraction changes cannot reuse an old payload.
67
65
  const bundleHash = sha256(JSON.stringify(['pdf-local-markdown-v1', capture.manifestSha256]));
@@ -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
+ }
@@ -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[];
@@ -11,10 +11,39 @@ export function unindexReadable(db, itemId) {
11
11
  if (row)
12
12
  db.prepare('DELETE FROM readable_fts WHERE rowid=?').run(row.id);
13
13
  }
14
- /** Literal phrase, bm25 rank then item id; excludes tombstones and superseded revisions. */
15
- export function lexicalSearch(db, query, limit) {
16
- const literal = '"' + query.replaceAll('"', '""') + '"';
14
+ // Function words match nearly every item, so they never decide whether one matches.
15
+ const stopwords = new Set('a an and are as at be but by can do does for from had has have how i if in into is it its me my no not of on or our so than that the their them then there these they this to too us was we were what when where which who why will with you your'.split(' '));
16
+ const literal = (text) => '"' + text.replaceAll('"', '""') + '"';
17
+ /** The query as one literal phrase; surrounding double quotes are the user's way of asking for exactly that. */
18
+ export function phraseMatch(query) {
19
+ const trimmed = query.trim();
20
+ return literal(/^"[^"]+"$/.test(trimmed) ? trimmed.slice(1, -1) : trimmed);
21
+ }
22
+ /** Any content word of the query; undefined for a quoted query or one of function words only. */
23
+ export function anyWordMatch(query) {
24
+ const trimmed = query.trim();
25
+ if (/^"[^"]+"$/.test(trimmed))
26
+ return undefined;
27
+ const words = [...new Set(trimmed.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [])].filter((w) => !stopwords.has(w));
28
+ return words.length ? words.map(literal).join(' OR ') : undefined;
29
+ }
30
+ /** bm25 with title matches weighted five times body, then item id; excludes tombstones and superseded revisions. */
31
+ export function lexicalChannel(db, expression, limit) {
32
+ if (expression === undefined)
33
+ return [];
17
34
  return db
18
- .prepare('SELECT i.id AS itemId,i.title,i.current_revision AS revisionId FROM readable_fts f JOIN readable_fts_rows m ON m.id=f.rowid JOIN items i ON i.id=m.item_id WHERE readable_fts MATCH ? AND i.deleted_at IS NULL AND i.current_revision=m.revision_id ORDER BY rank,i.id LIMIT ?')
19
- .all(literal, limit);
35
+ .prepare('SELECT i.id AS itemId,i.title,i.current_revision AS revisionId FROM readable_fts f JOIN readable_fts_rows m ON m.id=f.rowid JOIN items i ON i.id=m.item_id WHERE readable_fts MATCH ? AND i.deleted_at IS NULL AND i.current_revision=m.revision_id ORDER BY bm25(readable_fts,5.0,1.0),i.id LIMIT ?')
36
+ .all(expression, limit);
37
+ }
38
+ /**
39
+ * Items with the exact phrase first, then items with any of its content words. The phrase alone missed most vague
40
+ * queries, and any word alone buried the precise matches (scripts/eval/search.ts).
41
+ */
42
+ export function lexicalSearch(db, query, limit) {
43
+ const hits = new Map();
44
+ for (const expression of [phraseMatch(query), anyWordMatch(query)])
45
+ for (const hit of lexicalChannel(db, expression, limit))
46
+ if (!hits.has(hit.itemId))
47
+ hits.set(hit.itemId, hit);
48
+ return [...hits.values()].slice(0, limit);
20
49
  }
@@ -33,16 +33,16 @@
33
33
  }
34
34
  ],
35
35
  "build": {
36
- "asrSourceSha256": "17934fedff0c472a1a11f93849b20ec142334a25e035d5dfad40c9151b72c9eb",
36
+ "asrSourceSha256": "a90a44536ce014501d83964253fac06913e5cea442b930efcb9e394de191e39e",
37
37
  "clangVersion": "Apple clang version 21.0.0 (clang-2100.1.1.101)",
38
- "embeddingSourceSha256": "ca8d8bc22b03a3ca65a8ff4fb43f88d3d0cb3bd93591fe5842fd0106601aa6f1",
39
- "mediaSourceSha256": "dc775291679fd69e554e28ee4cbab457f4e79080fb58542d92a83bc0004cac82",
38
+ "embeddingSourceSha256": "0ecdc89d1e3591febbd1e2eab1acc85af10ddb00fa96077f52a627adff3fa28b",
39
+ "mediaSourceSha256": "a9cb79f88d235350ce857460b322cc5501839b886dbff2e8af9d55f5700f92eb",
40
40
  "rustcVersion": "rustc 1.97.1 (8bab26f4f 2026-07-14)",
41
41
  "swiftVersion": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)"
42
42
  },
43
43
  "minimumMacOS": "26.0",
44
- "packageVersion": "0.6.1",
44
+ "packageVersion": "0.6.2",
45
45
  "platform": "darwin",
46
46
  "schemaVersion": 1,
47
- "sourceCommit": "e5785b1c0df60222bf5be92d24ecefeab921712d"
47
+ "sourceCommit": "7dad1c9a3501ac00c361f8247da8d4f655d9952e"
48
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/sponge",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "A local library for captured articles and verified offline assets.",
5
5
  "repository": {
6
6
  "type": "git",