pikelet-wasm 0.12.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -8,6 +8,84 @@ Through 0.6.0 these were published as `pancake-wasm` and
8
8
  packages are pre-1.0: a minor bump may carry breaking changes, and each entry lists them
9
9
  first.
10
10
 
11
+ ## pikelet-wasm 0.13.0 / pikelet 0.13.0 — 2026-11-01
12
+
13
+ Mounting a pack over HTTP is much cheaper the second time: `pikelet mcp`
14
+ now keeps the two largest mount reads of a URL pack on disk, so a fresh
15
+ process mounting the 649 MiB Wikipedia pack from R2 transfers 9.1 MiB
16
+ instead of 80.1 MiB. A slow remote mount no longer leaves an MCP client
17
+ with no tools. `compile` and `append` now name what they drop instead of
18
+ dropping it silently, and a layered pack keeps its name over MCP. Upgrade
19
+ if you mount packs by URL or update them with `append`. Packs compiled
20
+ before this release still verify and serve, and compiling the same inputs
21
+ gives the same identities as 0.12.0. Both packages move to 0.13.0 together,
22
+ and `pikelet` requires `pikelet-wasm` ^0.13.0.
23
+
24
+ ### Compatibility
25
+
26
+ - **`pikelet mcp` writes a cache for URL packs.** The resident index prefix
27
+ and the query encoder of each URL pack mounted (about 71 MiB for the
28
+ Wikipedia pack) are stored in `~/.cache/pikelet/blobs`, up to 2 GiB,
29
+ evicting least recently used entries first. `$PIKELET_CACHE_DIR` or
30
+ `$XDG_CACHE_HOME` moves it, and `--no-cache` turns it off. Local packs
31
+ never use it.
32
+ - **A layered pack mounts under its pack's name.** A chain is named like a
33
+ single pack: from the shelf entry, else the base's manifest name, else
34
+ the file name with the `.0002` layer number dropped. A chain mounted from
35
+ `docs.0002.pikelet` was served as `docs.0002`; it is now `docs`, the
36
+ name its base alone mounted under. Tool calls that name the old pack
37
+ need updating. A single pack keeps a four-digit suffix (`wiki.2025`).
38
+ - **A missing calibration questions file is an error.** A
39
+ `--calibration-queries` file (or `runtime.calibrationQueries`) that does
40
+ not exist used to calibrate without the questions and say nothing; it
41
+ now fails, naming the path.
42
+ - **`tools/list` can answer before the packs have mounted.** After 2
43
+ seconds it answers with the same tools described without pack names,
44
+ uncached, and sends `notifications/tools/list_changed` once the mounts
45
+ settle; `initialize` and `server/discover` advertise
46
+ `tools.listChanged`. Tool calls still wait for the mounts.
47
+
48
+ ### Added
49
+
50
+ - **On-disk cache for URL packs (`pikelet-wasm`).** `openPikeletFile`
51
+ takes `blobCache`, a store keyed by sha256. It holds the sketch's
52
+ resident prefix and the query-interp segment that carries the encoder.
53
+ Every hit is hashed against its key, so a corrupt entry is a miss that
54
+ is deleted and refetched, and cache faults never fail an open.
55
+ `diskBlobCache()` and `defaultBlobCacheDir()` (Node, from
56
+ `pikelet-wasm/complete`) provide one on disk, and `pikelet mcp` uses it
57
+ for URL packs. Measured against the Wikipedia pack on R2, a fresh
58
+ process went from 80.1 MiB and 11.5 s to its first answer down to
59
+ 9.1 MiB and 6.9 s. Per-query reads are not cached.
60
+ - **A chain reports its pack's name.** `openPikeletChain(...).info().name`
61
+ is the base's name.
62
+
63
+ ### Fixed
64
+
65
+ - **A slow remote mount left an MCP client with no tools.** `tools/list`
66
+ waited for every mount; when a URL mount stalled (6.3 MiB in 37 s from
67
+ R2), Claude Code gave up on the list and started the session with no
68
+ tools at all. See Compatibility for how it answers now.
69
+ - **A calibration question naming no record was dropped silently.** Each
70
+ is now logged by name with the reason, and with the title it probably
71
+ meant when Markdown formatting is the difference (titles are matched
72
+ after formatting is stripped). An `expectId` outside the corpus is named
73
+ the same way.
74
+ - **`append` dropped short pages without saying which.** Documents under
75
+ 25 whitespace-separated tokens are skipped whole; `compile` and `append`
76
+ now log them with their token counts, and `append`'s refusal when
77
+ nothing is left names them.
78
+ - **`append`'s closing hint was not a usable next step.** It suggested
79
+ `openPikeletChain([...])`, which takes a query vector, not text; it now
80
+ prints `pikelet mcp --pack <layer>#<identity>`.
81
+
82
+ ### Documentation
83
+
84
+ - **Deleting is not redacting.** `append --remove` and `--supersede` take a
85
+ record out of search, but earlier files keep its bytes and `get_record`
86
+ serves it by id, marked `tombstoned`, so old citations resolve.
87
+ `compact` writes a base without it. Both READMEs say so.
88
+
11
89
  ## pikelet-wasm 0.12.0 / pikelet 0.12.0 — 2026-10-30
12
90
 
13
91
  Query embedding is about 1.65x faster, because the inline encoder now
package/README.md CHANGED
@@ -88,7 +88,7 @@ npx pikelet append --parent docs.0001.pikelet --remove 412 --out docs.0002.pikel
88
88
  npx pikelet mcp --pack docs.0002.pikelet
89
89
  ```
90
90
 
91
- On a 20-record test pack with a 24.6 MiB base, deleting a record took a 1.2 KiB layer and replacing one took 6.9 KiB. Mounting the newest layer finds and verifies the rest of the chain, locally or over HTTP, and `#<sha256>` on it pins the whole history. Layers inherit the base's calibration until the chain drifts past 20% of the base's records; then it serves `unscored` until `compact` folds it into a fresh base. The format is specified in [`LAYERED_PROFILE.md`](LAYERED_PROFILE.md).
91
+ On a 20-record test pack with a 24.6 MiB base, deleting a record took a 1.2 KiB layer and replacing one took 6.9 KiB. Deleting hides a record from search but does not erase it: the older files still hold its bytes, and `get_record` still serves it by id, marked `tombstoned`, so old citations resolve. `compact` writes a new base without it. Mounting the newest layer finds and verifies the rest of the chain, locally or over HTTP, and `#<sha256>` on it pins the whole history. Layers inherit the base's calibration until the chain drifts past 20% of the base's records; then it serves `unscored` until `compact` folds it into a fresh base. The format is specified in [`LAYERED_PROFILE.md`](LAYERED_PROFILE.md).
92
92
 
93
93
  ## Match quality
94
94
 
@@ -0,0 +1,111 @@
1
+ // A disk store for openPikeletFile's options.blobCache: whole regions a pack
2
+ // commits to by sha256 (the sketch's resident prefix, the query-interp
3
+ // segment holding the encoder), filed under that digest. Those regions are
4
+ // most of what a URL mount downloads (about 71 of 77 MiB on the 649 MiB
5
+ // Simple English Wikipedia pack), so a second process mounting the same pack
6
+ // reads them from disk instead of the network.
7
+ //
8
+ // The store is untrusted by design: the reader hashes every hit against the
9
+ // digest it asked for and treats a mismatch as a miss (blobCacheGet in
10
+ // pikelet-artifact-common.js), so a torn write or a corrupted file costs one
11
+ // refetch, never a wrong byte. Entries are immutable and never go stale; the
12
+ // only policy is a size budget, enforced by evicting least recently used
13
+ // entries (by mtime, touched on every hit) after each write.
14
+
15
+ import fs from 'node:fs/promises';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+
19
+ const KEY = /^[0-9a-f]{64}$/;
20
+ export const DEFAULT_BLOB_CACHE_BYTES = 2 * 1024 * 1024 * 1024;
21
+
22
+ // $PIKELET_CACHE_DIR, else $XDG_CACHE_HOME/pikelet/blobs, else
23
+ // ~/.cache/pikelet/blobs: the same root the CLI already caches encoder
24
+ // weights under.
25
+ export function defaultBlobCacheDir(env = process.env) {
26
+ if (env.PIKELET_CACHE_DIR) return path.resolve(env.PIKELET_CACHE_DIR, 'blobs');
27
+ const base = env.XDG_CACHE_HOME ? path.resolve(env.XDG_CACHE_HOME) : path.join(os.homedir(), '.cache');
28
+ return path.join(base, 'pikelet', 'blobs');
29
+ }
30
+
31
+ export function diskBlobCache(dir = defaultBlobCacheDir(), { maxBytes = DEFAULT_BLOB_CACHE_BYTES } = {}) {
32
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
33
+ throw new Error('diskBlobCache maxBytes must be a non-negative integer');
34
+ }
35
+ const root = path.resolve(dir);
36
+ const fileOf = (key) => {
37
+ if (!KEY.test(key)) throw new Error(`blob cache key must be a lowercase sha256 hex digest, got ${JSON.stringify(key)}`);
38
+ return path.join(root, key.slice(0, 2), key);
39
+ };
40
+ const pending = new Set();
41
+ const track = (promise) => {
42
+ pending.add(promise);
43
+ promise.finally(() => pending.delete(promise)).catch(() => {});
44
+ return promise;
45
+ };
46
+
47
+ const evict = async (keep) => {
48
+ const entries = [];
49
+ let total = 0;
50
+ for (const shard of await fs.readdir(root).catch(() => [])) {
51
+ const shardDir = path.join(root, shard);
52
+ for (const name of await fs.readdir(shardDir).catch(() => [])) {
53
+ if (!KEY.test(name)) continue;
54
+ const file = path.join(shardDir, name);
55
+ const st = await fs.stat(file).catch(() => null);
56
+ if (!st) continue;
57
+ total += st.size;
58
+ entries.push({ file, size: st.size, mtime: st.mtimeMs });
59
+ }
60
+ }
61
+ entries.sort((a, b) => a.mtime - b.mtime);
62
+ for (const entry of entries) {
63
+ if (total <= maxBytes) break;
64
+ if (entry.file === keep) continue;
65
+ await fs.rm(entry.file, { force: true });
66
+ total -= entry.size;
67
+ }
68
+ };
69
+
70
+ return {
71
+ dir: root,
72
+ async get(key) {
73
+ const file = fileOf(key);
74
+ let bytes;
75
+ try { bytes = await fs.readFile(file); } catch (err) {
76
+ if (err?.code === 'ENOENT') return null;
77
+ throw err;
78
+ }
79
+ // Touch for LRU; best effort.
80
+ const now = new Date();
81
+ fs.utimes(file, now, now).catch(() => {});
82
+ return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
83
+ },
84
+ put(key, bytes) {
85
+ const file = fileOf(key);
86
+ return track((async () => {
87
+ if (bytes.byteLength > maxBytes) return;
88
+ await fs.mkdir(path.dirname(file), { recursive: true });
89
+ // Write-then-rename, so a reader in another process sees
90
+ // either no entry or a whole one.
91
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
92
+ try {
93
+ await fs.writeFile(tmp, bytes);
94
+ await fs.rename(tmp, file);
95
+ } catch (err) {
96
+ await fs.rm(tmp, { force: true });
97
+ throw err;
98
+ }
99
+ await evict(file);
100
+ })());
101
+ },
102
+ async delete(key) {
103
+ await fs.rm(fileOf(key), { force: true });
104
+ },
105
+ // Settles every write started so far: for tests, and for a process
106
+ // that wants its cache populated before it exits.
107
+ async flush() {
108
+ while (pending.size) await Promise.allSettled([...pending]);
109
+ },
110
+ };
111
+ }
@@ -96,6 +96,26 @@ export declare function nodeFetch(input: string, init?: {
96
96
  inflateGzip?: boolean;
97
97
  }): Promise<any>;
98
98
 
99
+ /** openPikeletFile's options.blobCache: bytes keyed by their sha256 (lowercase hex). */
100
+ export interface BlobCache {
101
+ get(key: string): Promise<Uint8Array | null> | Uint8Array | null;
102
+ put(key: string, bytes: Uint8Array): Promise<void> | void;
103
+ delete?(key: string): Promise<void> | void;
104
+ }
105
+
106
+ /**
107
+ * Node only (the 'node' export condition): a BlobCache on disk, one file per
108
+ * key under `dir` (default defaultBlobCacheDir()), evicting least recently
109
+ * used entries past maxBytes (default 2 GiB). flush() settles pending writes.
110
+ */
111
+ export declare function diskBlobCache(dir?: string, options?: { maxBytes?: number }): BlobCache & {
112
+ dir: string;
113
+ flush(): Promise<void>;
114
+ };
115
+ /** $PIKELET_CACHE_DIR/blobs, else $XDG_CACHE_HOME/pikelet/blobs, else ~/.cache/pikelet/blobs. */
116
+ export declare function defaultBlobCacheDir(env?: Record<string, string | undefined>): string;
117
+ export declare const DEFAULT_BLOB_CACHE_BYTES: number;
118
+
99
119
  export interface CompleteQueryResult {
100
120
  /**
101
121
  * The pack's calibrated verdict on this retrieval. On packs calibrated
@@ -287,6 +307,17 @@ export declare function openPikeletFile(
287
307
  * true.
288
308
  */
289
309
  prefetchEncoder?: boolean;
310
+ /**
311
+ * A store for the regions a pack commits to by sha256 that dominate a
312
+ * cold URL mount: the sketch's resident prefix and the query-interp
313
+ * segment holding the inline encoder. Keys are those digests (lowercase
314
+ * hex), so entries never go stale. Every hit is hashed against its key
315
+ * before use and a mismatch is treated as a miss (and deleted), so the
316
+ * store need not be trusted; get/put/delete faults never fail an open.
317
+ * diskBlobCache() (Node) is a ready implementation. Ignored by an open
318
+ * whose resident verification is off.
319
+ */
320
+ blobCache?: BlobCache;
290
321
  /**
291
322
  * Fully verify the index's lazy vector rows at open (one streamed pass
292
323
  * against the identity-anchored vectorsSha256). Default false: without
@@ -27,6 +27,7 @@ import { createAbstentionScorer } from './retrieval-abstention.mjs';
27
27
  import { openLexicalIndex, openLexicalIndexLazy } from './lexical.mjs';
28
28
  import { parseRowMapSegment } from './rowmap.mjs';
29
29
  import { PikeletSketchArtifact } from '../src/artifact/pikelet-artifact.js';
30
+ import { blobCacheGet, blobCachePut } from '../src/artifact/pikelet-artifact-common.js';
30
31
  import { MAGIC, HEADER_BYTES, TABLE_ENTRY_BYTES, KINDS, KIND_NAMES } from './format.mjs';
31
32
 
32
33
  import {
@@ -785,6 +786,7 @@ export async function openPikeletFile(input, options = {}) {
785
786
  // the same bytes the sketch parses (see expectedHeaderSha256).
786
787
  PikeletSketchArtifact.open(windowSource(source, idx.offset, idx.length), {
787
788
  maxReadBytes,
789
+ ...(options.blobCache ? { blobCache: options.blobCache } : {}),
788
790
  ...(perRecord ? { expectedHeaderSha256: manifest.index.headerSha256 } : {}),
789
791
  }),
790
792
  readChecked(source, corpus.offset, tablesBytes, 'corpus tables', maxReadBytes, fileBytes),
@@ -1004,9 +1006,16 @@ export async function openPikeletFile(input, options = {}) {
1004
1006
  const loadEmbedder = async () => {
1005
1007
  let encBytes = encoderBytes;
1006
1008
  if (qiDeferred) {
1007
- const full = await readChecked(source, qi.offset, qi.length, 'query-interp segment', maxReadBytes, fileBytes);
1008
- if (await sha256hex(full) !== qi.sha256) {
1009
- throw new Error('.pikelet query-interp segment failed hash verification');
1009
+ // The ~25 MiB encoder is the other bulk of a cold mount;
1010
+ // options.blobCache serves it by the manifest's segment
1011
+ // digest (hash-checked on the way out, as a fetch is).
1012
+ const cached = await blobCacheGet(options.blobCache, qi.sha256, qi.length);
1013
+ const full = cached || await readChecked(source, qi.offset, qi.length, 'query-interp segment', maxReadBytes, fileBytes);
1014
+ if (!cached) {
1015
+ if (await sha256hex(full) !== qi.sha256) {
1016
+ throw new Error('.pikelet query-interp segment failed hash verification');
1017
+ }
1018
+ blobCachePut(options.blobCache, qi.sha256, full);
1010
1019
  }
1011
1020
  // The header and calibration slices used at open were
1012
1021
  // unverified; the digest-verified segment must contain
@@ -9,6 +9,7 @@ import { nodeFetch } from './node-fetch.mjs';
9
9
 
10
10
  export * from './index.mjs';
11
11
  export { nodeFetch };
12
+ export { diskBlobCache, defaultBlobCacheDir, DEFAULT_BLOB_CACHE_BYTES } from './blob-cache.node.mjs';
12
13
 
13
14
  export function httpRangeSource(url, options = {}) {
14
15
  return portableHttpRangeSource(url, { fetch: nodeFetch, ...options });
@@ -369,6 +369,10 @@ export async function openPikeletChain(members, options = {}) {
369
369
  profile: LAYER_PROFILE,
370
370
  identity: chainMembers[chainMembers.length - 1].identity,
371
371
  baseIdentity: base.identity,
372
+ // The pack's name is the base's: layers add records, not a
373
+ // new name. Without it a host fell back to naming the
374
+ // chain after its newest layer's file.
375
+ name: opened[0].info().name ?? null,
372
376
  layers: chainMembers.length,
373
377
  searchTiers: searchTiers.length,
374
378
  records: table.rowTotal,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikelet-wasm",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Vector search engine and .pikelet artifact readers/builders, in WebAssembly. Compile a corpus into one self-contained, hash-verified search file, or use the underlying HNSW engine directly. Runs in Node.js, browsers, and Cloudflare Workers with no native dependencies.",
5
5
  "main": "src/pikelet.js",
6
6
  "types": "src/pikelet.d.ts",
@@ -462,6 +462,36 @@ async function sha256BytesAsync(bytes) {
462
462
  }
463
463
  }
464
464
 
465
+ // Read-through cache for whole regions that carry a sha256 commitment (the
466
+ // sketch's resident prefix, a pack's query-interp segment), keyed by that
467
+ // digest. A hit is hashed exactly as a network read would be, so the cache
468
+ // can only ever hand back the committed bytes: anything else it holds (a
469
+ // partial write, disk corruption, a bug elsewhere) reads as a miss and the
470
+ // entry is dropped. Because the key is the content, entries never go stale
471
+ // and need no invalidation. Cache faults never fail a read.
472
+ async function blobCacheGet(cache, digestHex, length) {
473
+ if (!cache || typeof cache.get !== 'function') return null;
474
+ let hit;
475
+ try {
476
+ hit = await cache.get(digestHex);
477
+ if (!hit) return null;
478
+ hit = asUint8Array(hit);
479
+ } catch { return null; }
480
+ const digest = hit.byteLength === length ? await sha256BytesAsync(hit) : null;
481
+ let hex = '';
482
+ if (digest) for (const b of digest) hex += b.toString(16).padStart(2, '0');
483
+ if (hex === digestHex) return hit;
484
+ try { await cache.delete?.(digestHex); } catch { /* a miss either way */ }
485
+ return null;
486
+ }
487
+
488
+ // Store verified bytes. Fire-and-forget: a slow or failing cache must not
489
+ // hold up the open that just fetched them.
490
+ function blobCachePut(cache, digestHex, bytes) {
491
+ if (!cache || typeof cache.put !== 'function') return;
492
+ Promise.resolve().then(() => cache.put(digestHex, bytes)).catch(() => {});
493
+ }
494
+
465
495
  // Verify bytes against an expected 32-byte SHA-256 digest. Verification was
466
496
  // requested by the caller, so a missing crypto backend fails closed rather
467
497
  // than admitting unverified bytes.
@@ -556,6 +586,8 @@ module.exports = {
556
586
  parseUint8Snapshot,
557
587
  sha256Bytes,
558
588
  sha256BytesAsync,
589
+ blobCacheGet,
590
+ blobCachePut,
559
591
  verifySha256,
560
592
  verifySegmentSha256,
561
593
  };
@@ -17,6 +17,8 @@ const {
17
17
  parseUint8Snapshot,
18
18
  sha256Bytes,
19
19
  sha256BytesAsync,
20
+ blobCacheGet,
21
+ blobCachePut,
20
22
  verifySegmentSha256,
21
23
  } = require('./pikelet-artifact-common.js');
22
24
  const {
@@ -28,6 +30,12 @@ const {
28
30
  // ============================================================================
29
31
  // Sketch artifact profile (.pikelet-sketch) — spec/SKETCH_PROFILE.md
30
32
  // ============================================================================
33
+
34
+ function hexOf(bytes) {
35
+ let hex = '';
36
+ for (const b of bytes) hex += b.toString(16).padStart(2, '0');
37
+ return hex;
38
+ }
31
39
  //
32
40
  // Layout: 256-byte header | scales f32[count] | offsets f32[count] |
33
41
  // packed pooled sketches | zero padding to 16-byte alignment | raw u8 rows.
@@ -467,8 +475,16 @@ class PikeletSketchArtifact {
467
475
  artifact.tier = 'full';
468
476
  artifact.fullyResident = Promise.resolve(artifact);
469
477
 
470
- if (!staged) {
471
- const resident = await readChecked(source, SKETCH_HEADER_BYTES, vectorsOffset - SKETCH_HEADER_BYTES, 'resident prefix', artifact.maxReadBytes);
478
+ // options.blobCache: the resident prefix is the bulk of a mount (46 MiB
479
+ // of a 649 MiB wiki pack) and is committed by residentSha256, so a
480
+ // cache keyed by that digest serves it to later processes. A hit is
481
+ // hash-checked inside blobCacheGet; only verified opens consult it,
482
+ // since verify:false has nothing to check a hit against. A cache hit
483
+ // also skips staging: the full tier is already local.
484
+ const cacheKey = verify && options.blobCache ? hexOf(header.subarray(56, 88)) : null;
485
+ const cached = cacheKey ? await blobCacheGet(options.blobCache, cacheKey, vectorsOffset - SKETCH_HEADER_BYTES) : null;
486
+ if (!staged || cached) {
487
+ const resident = cached || await readChecked(source, SKETCH_HEADER_BYTES, vectorsOffset - SKETCH_HEADER_BYTES, 'resident prefix', artifact.maxReadBytes);
472
488
  if (resident.byteLength !== vectorsOffset - SKETCH_HEADER_BYTES) {
473
489
  throw pikeletError(PIKELET_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact resident prefix is truncated');
474
490
  }
@@ -477,7 +493,9 @@ class PikeletSketchArtifact {
477
493
  const residentCopy = new Uint8Array(resident.byteLength);
478
494
  residentCopy.set(resident);
479
495
  artifact._adoptResident(residentCopy, count, sketchRowBytes, microRowBytes, microOffset);
480
- if (verify) await artifact._verifyFullResident(residentCopy, header);
496
+ if (cached) artifact.residentVerified = true;
497
+ else if (verify) await artifact._verifyFullResident(residentCopy, header);
498
+ if (cacheKey && !cached) blobCachePut(options.blobCache, cacheKey, residentCopy);
481
499
  return artifact;
482
500
  }
483
501