pikelet-wasm 0.12.0 → 0.14.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,160 @@ 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.14.0 / pikelet 0.14.0 — 2026-11-04
12
+
13
+ Asking a URL pack a question an earlier process already asked is now
14
+ mostly local too: `pikelet mcp` keeps the verified rows, records and digest
15
+ pages a query reads in its disk cache, so on the Wikipedia pack over R2 a
16
+ repeated question transferred 178 KiB instead of 1.3 MiB and answered in
17
+ 0.65 s instead of 1.45 s. The cache is now private to its owner (0.13.0
18
+ left it readable by other local users under the usual umask), and two
19
+ processes sharing it no longer mis-index each other's writes. Upgrade if
20
+ you mount packs by URL, and especially if other people have accounts on
21
+ the machine. Packs compiled before this release still verify and serve,
22
+ and compiling the same inputs gives the same identities as 0.13.0. Both
23
+ packages move to 0.14.0 together, and `pikelet` requires `pikelet-wasm`
24
+ ^0.14.0.
25
+
26
+ ### Compatibility
27
+
28
+ - **The cache also holds query reads.** On top of the mount entries (up
29
+ to 2 GiB), URL packs now store what queries read in append-only logs
30
+ under `~/.cache/pikelet/blobs/small`, up to 64 MiB. `--no-cache` still
31
+ turns all of it off, and local packs still never use it.
32
+ - **The cache root becomes 0700.** The first write tightens the store's
33
+ directory to owner-only, including a `$PIKELET_CACHE_DIR/blobs` or a
34
+ directory passed to `diskBlobCache(dir)` that was opened up on purpose.
35
+ Nothing above that directory is touched.
36
+ - **Library callers: close a `diskBlobCache()` you are done with.** A store
37
+ that has served a query entry holds read handles on its log until
38
+ `close()`. Node 26 treats a file handle left to garbage collection as a
39
+ fatal error, so a store dropped without `close()` can end the process
40
+ there. The CLI keeps its store for the life of the process and is not
41
+ affected.
42
+
43
+ ### Added
44
+
45
+ - **Queries use the disk cache too.** With `blobCache`, the verified pieces
46
+ a query reads are stored and served by digest: rerank vector rows (by
47
+ their row digests; format 2), corpus records (by their record digests),
48
+ and the digest pages that verify both (by their page-table hashes). Every
49
+ hit is hash-checked against its key, a truncated row digest of at least
50
+ 128 bits included, and only reads verified on their own use the cache:
51
+ the lazy lexical index never does. `diskBlobCache()` keeps entries under
52
+ 64 KiB in append-only log generations (64 MiB in total by default,
53
+ `smallMaxBytes`; the oldest generation dropped whole, hits rewritten to
54
+ the newest) instead of one file each, and gains `close()`. On the
55
+ Wikipedia pack over R2, asking a question an earlier process already
56
+ asked transferred 178 KiB in 11 requests instead of 1.3 MiB in 29, and
57
+ answered in 0.65 s instead of 1.45 s; a new question costs what it did.
58
+
59
+ ### Fixed
60
+
61
+ - **Generated configs point at a schema that exists.** The configs the
62
+ CLI generates carried a `$schema` URL from before the repository moved
63
+ to `packages/`, which returned 404; it and the schema's own `$id` now name
64
+ `packages/pikelet/schemas/v1/pikelet.config.schema.json`. Configs
65
+ generated earlier keep the old URL until it is edited.
66
+ - **Two processes sharing a disk cache no longer mis-index each other's
67
+ appends.** A store computed a log batch's offset as the file size after
68
+ its append, so another process appending in between left its index
69
+ pointing at the wrong record: about a third of each writer's own
70
+ lookups across three processes. Every hit is hash-checked, so this cost
71
+ refetches, never a wrong byte, and a fresh process re-indexed the log
72
+ correctly. A batch is now read back at its offset before it is indexed,
73
+ and if the end moved, the generation is rescanned to find where it
74
+ landed.
75
+
76
+ ### Security
77
+
78
+ - **The disk cache is private to its owner.** `diskBlobCache()` stored
79
+ entries with the process umask, so under the usual 022 its directories
80
+ were 0755 and its files 0644: readable by other local users wherever
81
+ the parent directories allowed it. Entries hold record text, which for a
82
+ pack of a private repository, a journal, or conversation history is the
83
+ source itself. Directories are now created 0700 and files 0600, and the
84
+ first write tightens the store's root to 0700, which also closes off
85
+ entries a 0.13.0 cache already wrote.
86
+
87
+ ## pikelet-wasm 0.13.0 / pikelet 0.13.0 — 2026-11-01
88
+
89
+ Mounting a pack over HTTP is much cheaper the second time: `pikelet mcp`
90
+ now keeps the two largest mount reads of a URL pack on disk, so a fresh
91
+ process mounting the 649 MiB Wikipedia pack from R2 transfers 9.1 MiB
92
+ instead of 80.1 MiB. A slow remote mount no longer leaves an MCP client
93
+ with no tools. `compile` and `append` now name what they drop instead of
94
+ dropping it silently, and a layered pack keeps its name over MCP. Upgrade
95
+ if you mount packs by URL or update them with `append`. Packs compiled
96
+ before this release still verify and serve, and compiling the same inputs
97
+ gives the same identities as 0.12.0. Both packages move to 0.13.0 together,
98
+ and `pikelet` requires `pikelet-wasm` ^0.13.0.
99
+
100
+ ### Compatibility
101
+
102
+ - **`pikelet mcp` writes a cache for URL packs.** The resident index prefix
103
+ and the query encoder of each URL pack mounted (about 71 MiB for the
104
+ Wikipedia pack) are stored in `~/.cache/pikelet/blobs`, up to 2 GiB,
105
+ evicting least recently used entries first. `$PIKELET_CACHE_DIR` or
106
+ `$XDG_CACHE_HOME` moves it, and `--no-cache` turns it off. Local packs
107
+ never use it.
108
+ - **A layered pack mounts under its pack's name.** A chain is named like a
109
+ single pack: from the shelf entry, else the base's manifest name, else
110
+ the file name with the `.0002` layer number dropped. A chain mounted from
111
+ `docs.0002.pikelet` was served as `docs.0002`; it is now `docs`, the
112
+ name its base alone mounted under. Tool calls that name the old pack
113
+ need updating. A single pack keeps a four-digit suffix (`wiki.2025`).
114
+ - **A missing calibration questions file is an error.** A
115
+ `--calibration-queries` file (or `runtime.calibrationQueries`) that does
116
+ not exist used to calibrate without the questions and say nothing; it
117
+ now fails, naming the path.
118
+ - **`tools/list` can answer before the packs have mounted.** After 2
119
+ seconds it answers with the same tools described without pack names,
120
+ uncached, and sends `notifications/tools/list_changed` once the mounts
121
+ settle; `initialize` and `server/discover` advertise
122
+ `tools.listChanged`. Tool calls still wait for the mounts.
123
+
124
+ ### Added
125
+
126
+ - **On-disk cache for URL packs (`pikelet-wasm`).** `openPikeletFile`
127
+ takes `blobCache`, a store keyed by sha256. It holds the sketch's
128
+ resident prefix and the query-interp segment that carries the encoder.
129
+ Every hit is hashed against its key, so a corrupt entry is a miss that
130
+ is deleted and refetched, and cache faults never fail an open.
131
+ `diskBlobCache()` and `defaultBlobCacheDir()` (Node, from
132
+ `pikelet-wasm/complete`) provide one on disk, and `pikelet mcp` uses it
133
+ for URL packs. Measured against the Wikipedia pack on R2, a fresh
134
+ process went from 80.1 MiB and 11.5 s to its first answer down to
135
+ 9.1 MiB and 6.9 s. Per-query reads are not cached.
136
+ - **A chain reports its pack's name.** `openPikeletChain(...).info().name`
137
+ is the base's name.
138
+
139
+ ### Fixed
140
+
141
+ - **A slow remote mount left an MCP client with no tools.** `tools/list`
142
+ waited for every mount; when a URL mount stalled (6.3 MiB in 37 s from
143
+ R2), Claude Code gave up on the list and started the session with no
144
+ tools at all. See Compatibility for how it answers now.
145
+ - **A calibration question naming no record was dropped silently.** Each
146
+ is now logged by name with the reason, and with the title it probably
147
+ meant when Markdown formatting is the difference (titles are matched
148
+ after formatting is stripped). An `expectId` outside the corpus is named
149
+ the same way.
150
+ - **`append` dropped short pages without saying which.** Documents under
151
+ 25 whitespace-separated tokens are skipped whole; `compile` and `append`
152
+ now log them with their token counts, and `append`'s refusal when
153
+ nothing is left names them.
154
+ - **`append`'s closing hint was not a usable next step.** It suggested
155
+ `openPikeletChain([...])`, which takes a query vector, not text; it now
156
+ prints `pikelet mcp --pack <layer>#<identity>`.
157
+
158
+ ### Documentation
159
+
160
+ - **Deleting is not redacting.** `append --remove` and `--supersede` take a
161
+ record out of search, but earlier files keep its bytes and `get_record`
162
+ serves it by id, marked `tombstoned`, so old citations resolve.
163
+ `compact` writes a base without it. Both READMEs say so.
164
+
11
165
  ## pikelet-wasm 0.12.0 / pikelet 0.12.0 — 2026-10-30
12
166
 
13
167
  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,337 @@
1
+ // A disk store for openPikeletFile's options.blobCache: bytes a pack commits
2
+ // to by sha256, filed under that digest. Two kinds of entry reach it:
3
+ //
4
+ // - whole regions read at mount (the sketch's resident prefix, the
5
+ // query-interp segment holding the encoder): most of what a URL mount
6
+ // downloads, about 71 of 77 MiB on the 649 MiB Simple English Wikipedia
7
+ // pack. One file each.
8
+ // - pieces read per query (rerank vector rows, corpus records, and the
9
+ // digest pages that verify them): a few hundred bytes to a few KiB, and
10
+ // hundreds per query. One file each would be a poor fit, so entries under
11
+ // SMALL_ENTRY_BYTES go into append-only log files (generations) with an
12
+ // in-memory index, and whole generations rotate out under their own
13
+ // budget.
14
+ //
15
+ // The store is untrusted by design: the reader hashes every hit against the
16
+ // digest it asked for and treats a mismatch as a miss (blobCacheGet in
17
+ // pikelet-artifact-common.js), so a torn write, a corrupted file, or two
18
+ // processes appending to one log at once costs a refetch, never a wrong byte.
19
+ // Entries are immutable and never go stale; the only policy is size.
20
+
21
+ import fs from 'node:fs/promises';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+
25
+ // A full sha256, or a truncated one of at least 128 bits (sketch row digests
26
+ // may be 16-32 bytes).
27
+ const KEY = /^(?:[0-9a-f]{2}){16,32}$/;
28
+ export const DEFAULT_BLOB_CACHE_BYTES = 2 * 1024 * 1024 * 1024;
29
+ export const DEFAULT_SMALL_CACHE_BYTES = 64 * 1024 * 1024;
30
+ // Entries below this go to the log; at or above it, a file of their own.
31
+ export const SMALL_ENTRY_BYTES = 64 * 1024;
32
+ // The log keeps this many generations; the oldest is deleted whole when the
33
+ // newest fills (smallMaxBytes / GENERATIONS each).
34
+ const GENERATIONS = 4;
35
+ // Log record: magic 'PKB1' (u32 LE), key length (u8), key bytes, data length
36
+ // (u32 LE), data. A scan stops at the first record that does not parse.
37
+ const RECORD_MAGIC = 0x3142_4b50;
38
+ const GEN_FILE = /^gen-(\d{8})\.log$/;
39
+ // Entries are pack content, record text included, and a pack can be a
40
+ // private repository or a conversation history: the store is private to its
41
+ // owner whatever the umask.
42
+ const DIR_MODE = 0o700;
43
+ const FILE_MODE = 0o600;
44
+
45
+ // $PIKELET_CACHE_DIR, else $XDG_CACHE_HOME/pikelet/blobs, else
46
+ // ~/.cache/pikelet/blobs: the same root the CLI already caches encoder
47
+ // weights under.
48
+ export function defaultBlobCacheDir(env = process.env) {
49
+ if (env.PIKELET_CACHE_DIR) return path.resolve(env.PIKELET_CACHE_DIR, 'blobs');
50
+ const base = env.XDG_CACHE_HOME ? path.resolve(env.XDG_CACHE_HOME) : path.join(os.homedir(), '.cache');
51
+ return path.join(base, 'pikelet', 'blobs');
52
+ }
53
+
54
+ export function diskBlobCache(dir = defaultBlobCacheDir(), { maxBytes = DEFAULT_BLOB_CACHE_BYTES, smallMaxBytes = DEFAULT_SMALL_CACHE_BYTES } = {}) {
55
+ for (const [label, value] of [['maxBytes', maxBytes], ['smallMaxBytes', smallMaxBytes]]) {
56
+ if (!Number.isSafeInteger(value) || value < 0) {
57
+ throw new Error(`diskBlobCache ${label} must be a non-negative integer`);
58
+ }
59
+ }
60
+ const root = path.resolve(dir);
61
+ const checkKey = (key) => {
62
+ if (typeof key !== 'string' || !KEY.test(key)) {
63
+ throw new Error(`blob cache key must be a lowercase sha256 hex digest (32-64 hex digits), got ${JSON.stringify(key)}`);
64
+ }
65
+ return key;
66
+ };
67
+ const fileOf = (key) => path.join(root, key.slice(0, 2), checkKey(key));
68
+ // Once per instance, before the first write: a root made by an older
69
+ // store (or by mkdir -p under umask 022) is tightened, which also closes
70
+ // off whatever it already holds. Best effort; on Windows modes are moot.
71
+ let privateRoot = null;
72
+ const makeRootPrivate = () => (privateRoot ??= (async () => {
73
+ await fs.mkdir(root, { recursive: true, mode: DIR_MODE });
74
+ await fs.chmod(root, DIR_MODE).catch(() => {});
75
+ })());
76
+ const pending = new Set();
77
+ const track = (promise) => {
78
+ pending.add(promise);
79
+ promise.finally(() => pending.delete(promise)).catch(() => {});
80
+ return promise;
81
+ };
82
+
83
+ const evict = async (keep) => {
84
+ const entries = [];
85
+ let total = 0;
86
+ for (const shard of await fs.readdir(root).catch(() => [])) {
87
+ if (shard === 'small') continue;
88
+ const shardDir = path.join(root, shard);
89
+ for (const name of await fs.readdir(shardDir).catch(() => [])) {
90
+ if (!KEY.test(name)) continue;
91
+ const file = path.join(shardDir, name);
92
+ const st = await fs.stat(file).catch(() => null);
93
+ if (!st) continue;
94
+ total += st.size;
95
+ entries.push({ file, size: st.size, mtime: st.mtimeMs });
96
+ }
97
+ }
98
+ entries.sort((a, b) => a.mtime - b.mtime);
99
+ for (const entry of entries) {
100
+ if (total <= maxBytes) break;
101
+ if (entry.file === keep) continue;
102
+ await fs.rm(entry.file, { force: true });
103
+ total -= entry.size;
104
+ }
105
+ };
106
+
107
+ // --- the small-entry log -------------------------------------------------
108
+ const smallDir = path.join(root, 'small');
109
+ const genBytes = Math.max(1, Math.floor(smallMaxBytes / GENERATIONS));
110
+ const genPath = (gen) => path.join(smallDir, `gen-${String(gen).padStart(8, '0')}.log`);
111
+ const index = new Map(); // key -> { gen, offset, length }
112
+ const handles = new Map(); // gen -> Promise<FileHandle> (read)
113
+ let current = 0;
114
+ // Set when the newest generation ends in bytes that do not parse (a torn
115
+ // write): a scan stops there, so anything appended after it would never
116
+ // be indexed again. Writing resumes in a fresh generation instead.
117
+ let sealed = false;
118
+ let queue = [];
119
+ let flushing = null;
120
+
121
+ const listGens = async () => (await fs.readdir(smallDir).catch(() => []))
122
+ .map((name) => GEN_FILE.exec(name)).filter(Boolean).map((m) => Number(m[1])).sort((a, b) => a - b);
123
+
124
+ const forgetGen = async (gen) => {
125
+ for (const [key, at] of index) if (at.gen === gen) index.delete(key);
126
+ const handle = handles.get(gen);
127
+ handles.delete(gen);
128
+ if (handle) await handle.then((h) => h.close(), () => {});
129
+ };
130
+
131
+ const scan = (gen, bytes) => {
132
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
133
+ let at = 0;
134
+ while (at + 9 <= bytes.length && view.getUint32(at, true) === RECORD_MAGIC) {
135
+ const keyLen = bytes[at + 4];
136
+ if (keyLen < 16 || keyLen > 32 || at + 5 + keyLen + 4 > bytes.length) break;
137
+ const key = Buffer.from(bytes.subarray(at + 5, at + 5 + keyLen)).toString('hex');
138
+ const length = view.getUint32(at + 5 + keyLen, true);
139
+ const offset = at + 9 + keyLen;
140
+ if (offset + length > bytes.length) break;
141
+ index.set(key, { gen, offset, length });
142
+ at = offset + length;
143
+ }
144
+ return at === bytes.length;
145
+ };
146
+
147
+ // Built once, on first use: every generation still on disk is read and
148
+ // indexed (at most smallMaxBytes), later generations overriding earlier.
149
+ let ready = null;
150
+ const smallReady = () => (ready ??= (async () => {
151
+ if (smallMaxBytes === 0) return;
152
+ const gens = await listGens();
153
+ for (const gen of gens.slice(0, Math.max(0, gens.length - GENERATIONS))) {
154
+ await fs.rm(genPath(gen), { force: true });
155
+ }
156
+ for (const gen of gens.slice(-GENERATIONS)) {
157
+ const bytes = await fs.readFile(genPath(gen)).catch(() => null);
158
+ const whole = bytes ? scan(gen, bytes) : true;
159
+ if (gen === gens[gens.length - 1]) sealed = !whole;
160
+ }
161
+ current = gens.length ? gens[gens.length - 1] : 0;
162
+ })());
163
+
164
+ const readSmall = async (key) => {
165
+ const at = index.get(key);
166
+ if (!at) return null;
167
+ let handle = handles.get(at.gen);
168
+ if (!handle) {
169
+ handle = fs.open(genPath(at.gen), 'r');
170
+ handles.set(at.gen, handle);
171
+ handle.catch(() => { if (handles.get(at.gen) === handle) handles.delete(at.gen); });
172
+ }
173
+ let fh;
174
+ try { fh = await handle; } catch (err) {
175
+ // Another process rotated this generation away.
176
+ if (err?.code === 'ENOENT') { await forgetGen(at.gen); return null; }
177
+ throw err;
178
+ }
179
+ const out = new Uint8Array(at.length);
180
+ const { bytesRead } = await fh.read(out, 0, at.length, at.offset);
181
+ if (bytesRead !== at.length) { index.delete(key); return null; }
182
+ // A hit from an older generation is written again to the newest, so
183
+ // rotation drops what has not been read lately: LRU by generation.
184
+ if (at.gen !== current) enqueue(key, out);
185
+ return out;
186
+ };
187
+
188
+ const rotate = async () => {
189
+ await makeRootPrivate();
190
+ await fs.mkdir(smallDir, { recursive: true, mode: DIR_MODE });
191
+ const gens = await listGens();
192
+ current = Math.max(current, gens.length ? gens[gens.length - 1] : 0) + 1;
193
+ // Exclusive create; another process that rotated first owns the
194
+ // name, and appending after its records is fine.
195
+ await fs.writeFile(genPath(current), new Uint8Array(0), { flag: 'wx', mode: FILE_MODE }).catch((err) => {
196
+ if (err?.code !== 'EEXIST') throw err;
197
+ });
198
+ for (const gen of (await listGens()).filter((g) => g <= current - GENERATIONS)) {
199
+ await forgetGen(gen);
200
+ await fs.rm(genPath(gen), { force: true });
201
+ }
202
+ };
203
+
204
+ const flushSmall = async () => {
205
+ while (queue.length) {
206
+ const batch = queue;
207
+ queue = [];
208
+ const total = batch.reduce((n, r) => n + r.record.length, 0);
209
+ if (!current || sealed) {
210
+ await rotate();
211
+ sealed = false;
212
+ } else {
213
+ const st = await fs.stat(genPath(current)).catch(() => null);
214
+ if (!st || st.size + total > genBytes) await rotate();
215
+ }
216
+ const buf = Buffer.concat(batch.map((r) => r.record));
217
+ const fh = await fs.open(genPath(current), 'a+', FILE_MODE);
218
+ let base;
219
+ try {
220
+ await fh.write(buf);
221
+ // O_APPEND puts the batch at the end; its offset is the size
222
+ // now minus its length, unless another process appended
223
+ // between the write and the stat. So the batch is read back
224
+ // there before it is indexed: records are content-addressed,
225
+ // so wherever these exact bytes sit is a right offset.
226
+ base = (await fh.stat()).size - buf.length;
227
+ if (base >= 0) {
228
+ const back = Buffer.alloc(buf.length);
229
+ const { bytesRead } = await fh.read(back, 0, buf.length, base);
230
+ if (bytesRead !== buf.length || !back.equals(buf)) base = -1;
231
+ }
232
+ } finally {
233
+ await fh.close();
234
+ }
235
+ if (base < 0) {
236
+ // The end moved: find where the batch landed by scanning the
237
+ // generation, which indexes the other writer's records too.
238
+ // A record still being written stops the scan; whatever lies
239
+ // past it is a miss, not a wrong offset.
240
+ const bytes = await fs.readFile(genPath(current)).catch(() => null);
241
+ if (bytes) scan(current, bytes);
242
+ continue;
243
+ }
244
+ let at = base;
245
+ for (const r of batch) {
246
+ index.set(r.key, { gen: current, offset: at + r.headerBytes, length: r.length });
247
+ at += r.record.length;
248
+ }
249
+ }
250
+ };
251
+
252
+ const enqueue = (key, bytes) => {
253
+ if (index.has(key) && index.get(key).gen === current) return;
254
+ const raw = Buffer.from(key, 'hex');
255
+ const headerBytes = 9 + raw.length;
256
+ const record = Buffer.alloc(headerBytes + bytes.byteLength);
257
+ record.writeUInt32LE(RECORD_MAGIC, 0);
258
+ record[4] = raw.length;
259
+ raw.copy(record, 5);
260
+ record.writeUInt32LE(bytes.byteLength, 5 + raw.length);
261
+ record.set(bytes, headerBytes);
262
+ queue.push({ key, record, headerBytes, length: bytes.byteLength });
263
+ // One append per burst: a query stores hundreds of rows at once.
264
+ flushing ??= track(new Promise((resolve) => setImmediate(resolve))
265
+ .then(() => flushSmall())
266
+ .finally(() => { flushing = null; }));
267
+ return flushing;
268
+ };
269
+
270
+ return {
271
+ dir: root,
272
+ async get(key) {
273
+ checkKey(key);
274
+ if (smallMaxBytes > 0) {
275
+ await smallReady();
276
+ const small = await readSmall(key);
277
+ if (small) return small;
278
+ }
279
+ const file = fileOf(key);
280
+ let bytes;
281
+ try { bytes = await fs.readFile(file); } catch (err) {
282
+ if (err?.code === 'ENOENT') return null;
283
+ throw err;
284
+ }
285
+ // Touch for LRU; best effort.
286
+ const now = new Date();
287
+ fs.utimes(file, now, now).catch(() => {});
288
+ return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
289
+ },
290
+ put(key, bytes) {
291
+ checkKey(key);
292
+ if (bytes.byteLength < SMALL_ENTRY_BYTES) {
293
+ if (smallMaxBytes === 0 || bytes.byteLength > genBytes) return Promise.resolve();
294
+ return track(smallReady().then(() => enqueue(key, bytes)));
295
+ }
296
+ const file = fileOf(key);
297
+ return track((async () => {
298
+ if (bytes.byteLength > maxBytes) return;
299
+ await makeRootPrivate();
300
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: DIR_MODE });
301
+ // Write-then-rename, so a reader in another process sees
302
+ // either no entry or a whole one.
303
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
304
+ try {
305
+ await fs.writeFile(tmp, bytes, { mode: FILE_MODE });
306
+ await fs.rename(tmp, file);
307
+ } catch (err) {
308
+ await fs.rm(tmp, { force: true });
309
+ throw err;
310
+ }
311
+ await evict(file);
312
+ })());
313
+ },
314
+ async delete(key) {
315
+ checkKey(key);
316
+ // A log entry cannot be removed in place; forgetting it is enough,
317
+ // and rotation reclaims the bytes.
318
+ index.delete(key);
319
+ await fs.rm(fileOf(key), { force: true });
320
+ },
321
+ // Settles every write started so far: for tests, and for a process
322
+ // that wants its cache populated before it exits.
323
+ async flush() {
324
+ while (pending.size) await Promise.allSettled([...pending]);
325
+ },
326
+ // Closes the log's read handles (they otherwise live as long as the
327
+ // process). The store stays usable; handles reopen on demand.
328
+ async close() {
329
+ await this.flush();
330
+ for (const gen of [...handles.keys()]) {
331
+ const handle = handles.get(gen);
332
+ handles.delete(gen);
333
+ await handle.then((h) => h.close(), () => {});
334
+ }
335
+ },
336
+ };
337
+ }
@@ -96,6 +96,32 @@ 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, 32-64 digits). */
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 under `dir`
108
+ * (default defaultBlobCacheDir()). Entries of 64 KiB or more get a file each,
109
+ * evicting least recently used past maxBytes (default 2 GiB). Smaller ones
110
+ * (per-query rows, records, digest pages) go to append-only log generations
111
+ * capped at smallMaxBytes in total (default 64 MiB; 0 disables them), the
112
+ * oldest generation dropped whole. flush() settles pending writes; close()
113
+ * releases the log's file handles.
114
+ */
115
+ export declare function diskBlobCache(dir?: string, options?: { maxBytes?: number; smallMaxBytes?: number }): BlobCache & {
116
+ dir: string;
117
+ flush(): Promise<void>;
118
+ close(): Promise<void>;
119
+ };
120
+ /** $PIKELET_CACHE_DIR/blobs, else $XDG_CACHE_HOME/pikelet/blobs, else ~/.cache/pikelet/blobs. */
121
+ export declare function defaultBlobCacheDir(env?: Record<string, string | undefined>): string;
122
+ export declare const DEFAULT_BLOB_CACHE_BYTES: number;
123
+ export declare const DEFAULT_SMALL_CACHE_BYTES: number;
124
+
99
125
  export interface CompleteQueryResult {
100
126
  /**
101
127
  * The pack's calibrated verdict on this retrieval. On packs calibrated
@@ -287,6 +313,21 @@ export declare function openPikeletFile(
287
313
  * true.
288
314
  */
289
315
  prefetchEncoder?: boolean;
316
+ /**
317
+ * A store for bytes a pack commits to by sha256. At mount: the sketch's
318
+ * resident prefix and the query-interp segment holding the inline
319
+ * encoder, which dominate a cold URL mount. Per query: rerank vector
320
+ * rows (format 2), corpus records, and the digest pages that verify
321
+ * both. Keys are those digests in lowercase hex (a row's digest may be
322
+ * truncated to 16-31 bytes), so entries never go stale. Every hit is
323
+ * hashed and must match its key (a prefix, for a truncated key); a
324
+ * mismatch is treated as a miss and deleted, so the store need not be
325
+ * trusted, and get/put/delete faults never fail a read. Only reads that
326
+ * are verified on their own use it: nothing when verification is off,
327
+ * and never the lazy lexical index. diskBlobCache() (Node) is a ready
328
+ * implementation.
329
+ */
330
+ blobCache?: BlobCache;
290
331
  /**
291
332
  * Fully verify the index's lazy vector rows at open (one streamed pass
292
333
  * 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 {
@@ -96,9 +97,9 @@ const COVERAGE_BATCH_COUNT = 5;
96
97
  // Only BM25 hits within this fraction of the top lexical score join fusion.
97
98
  // Common query terms give every matching document a near-tied score (idf
98
99
  // collapses them flat); a wide cutoff let that tied mass ride into fusion on
99
- // a technicality. Kept identical to pikelet/src/calibrate.mjs's
100
- // LEXICAL_CUTOFF so calibration is fit against the same fusion the reader
101
- // actually serves.
100
+ // a technicality. Calibration (pikelet/src/calibrate.mjs) gathers its
101
+ // evidence through this reader's own retrieval, so it is fit against the
102
+ // same cutoff and fusion the reader serves.
102
103
  export const LEXICAL_CUTOFF = 1.5;
103
104
  // Query-interpretation segments above this size open lazily (kind 3's
104
105
  // inline encoder is ~25 MiB); smaller ones keep the one-read eager path.
@@ -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
@@ -1191,12 +1200,16 @@ export async function openPikeletFile(input, options = {}) {
1191
1200
  const pending = (async () => {
1192
1201
  const from = Math.min(recordCount, page * pageRecords);
1193
1202
  const to = Math.min(recordCount, (page + 1) * pageRecords);
1203
+ const key = toHex(pageTable.subarray(32 * page, 32 * page + 32));
1204
+ const cached = await blobCacheGet(options.blobCache, key, 32 * (to - from));
1205
+ if (cached) return cached;
1194
1206
  const bytes = await readChecked(source, corpus.offset + recordDigestsAt + 32 * from, 32 * (to - from),
1195
1207
  `corpus digest page ${page}`, maxReadBytes, fileBytes);
1196
- if (await sha256hex(bytes) !== toHex(pageTable.subarray(32 * page, 32 * page + 32))) {
1208
+ if (await sha256hex(bytes) !== key) {
1197
1209
  digestPages.delete(page);
1198
1210
  throw new Error(`.pikelet corpus digest page ${page} failed hash verification`);
1199
1211
  }
1212
+ blobCachePut(options.blobCache, key, bytes);
1200
1213
  return bytes;
1201
1214
  })();
1202
1215
  digestPages.set(page, pending);
@@ -1225,14 +1238,29 @@ export async function openPikeletFile(input, options = {}) {
1225
1238
  }
1226
1239
  const start = recordOffsets[id];
1227
1240
  const end = recordOffsets[id + 1];
1228
- const bytes = await readChecked(source, corpus.offset + start, end - start, `corpus record ${id}`, maxRecordBytes, fileBytes);
1229
- if (perRecord && verifyRecords) {
1241
+ // With options.blobCache a verified record is keyed by its own
1242
+ // digest, so the digest comes first; another process may already
1243
+ // have fetched the record. Only verified reads use the cache.
1244
+ const cacheable = perRecord && verifyRecords && options.blobCache;
1245
+ let digest = null;
1246
+ if (cacheable) {
1230
1247
  const page = Math.floor(id / pageRecords);
1231
- const digests = await pageDigests(page);
1232
1248
  const at = 32 * (id - page * pageRecords);
1233
- if (await sha256hex(bytes) !== toHex(digests.subarray(at, at + 32))) {
1249
+ digest = toHex((await pageDigests(page)).subarray(at, at + 32));
1250
+ const cached = await blobCacheGet(options.blobCache, digest, end - start);
1251
+ if (cached) return cached;
1252
+ }
1253
+ const bytes = await readChecked(source, corpus.offset + start, end - start, `corpus record ${id}`, maxRecordBytes, fileBytes);
1254
+ if (perRecord && verifyRecords) {
1255
+ if (!digest) {
1256
+ const page = Math.floor(id / pageRecords);
1257
+ const at = 32 * (id - page * pageRecords);
1258
+ digest = toHex((await pageDigests(page)).subarray(at, at + 32));
1259
+ }
1260
+ if (await sha256hex(bytes) !== digest) {
1234
1261
  throw new Error(`.pikelet corpus record ${id} failed integrity verification`);
1235
1262
  }
1263
+ blobCachePut(options.blobCache, digest, bytes);
1236
1264
  }
1237
1265
  return bytes;
1238
1266
  };
@@ -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, DEFAULT_SMALL_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.14.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,42 @@ async function sha256BytesAsync(bytes) {
462
462
  }
463
463
  }
464
464
 
465
+ // Read-through cache for bytes a pack commits to by sha256: the whole
466
+ // regions a mount reads (the sketch's resident prefix, a pack's query-interp
467
+ // segment) and the pieces a query reads (rerank rows, corpus records, the
468
+ // digest pages that verify them), keyed by that digest. A key may be a
469
+ // truncated digest of at least 128 bits (sketch row digests are 16-32
470
+ // bytes), and a hit must hash to a digest that starts with it. A hit is
471
+ // hashed exactly as a network read would be, so the cache can only ever hand
472
+ // back the committed bytes: anything else it holds (a partial write, disk
473
+ // corruption, a bug elsewhere) reads as a miss and the entry is dropped.
474
+ // Because the key is the content, entries never go stale and need no
475
+ // invalidation. Cache faults never fail a read. `length`, when given, must
476
+ // match too.
477
+ async function blobCacheGet(cache, digestHex, length) {
478
+ if (!cache || typeof cache.get !== 'function') return null;
479
+ if (typeof digestHex !== 'string' || digestHex.length < 32 || digestHex.length > 64) return null;
480
+ let hit;
481
+ try {
482
+ hit = await cache.get(digestHex);
483
+ if (!hit) return null;
484
+ hit = asUint8Array(hit);
485
+ } catch { return null; }
486
+ const digest = length === undefined || hit.byteLength === length ? await sha256BytesAsync(hit) : null;
487
+ let hex = '';
488
+ if (digest) for (const b of digest) hex += b.toString(16).padStart(2, '0');
489
+ if (hex && hex.startsWith(digestHex)) return hit;
490
+ try { await cache.delete?.(digestHex); } catch { /* a miss either way */ }
491
+ return null;
492
+ }
493
+
494
+ // Store verified bytes. Fire-and-forget: a slow or failing cache must not
495
+ // hold up the open that just fetched them.
496
+ function blobCachePut(cache, digestHex, bytes) {
497
+ if (!cache || typeof cache.put !== 'function') return;
498
+ Promise.resolve().then(() => cache.put(digestHex, bytes)).catch(() => {});
499
+ }
500
+
465
501
  // Verify bytes against an expected 32-byte SHA-256 digest. Verification was
466
502
  // requested by the caller, so a missing crypto backend fails closed rather
467
503
  // than admitting unverified bytes.
@@ -556,6 +592,8 @@ module.exports = {
556
592
  parseUint8Snapshot,
557
593
  sha256Bytes,
558
594
  sha256BytesAsync,
595
+ blobCacheGet,
596
+ blobCachePut,
559
597
  verifySha256,
560
598
  verifySegmentSha256,
561
599
  };
@@ -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.
@@ -463,12 +471,23 @@ class PikeletSketchArtifact {
463
471
  // Per-row verification on format 2 follows the same switch as the
464
472
  // resident hash: on by default, verify:false opts the whole open out.
465
473
  artifact.verifyRows = version >= 2 && verify;
474
+ // Per-query pieces (digest pages and rows) use the same cache as the
475
+ // resident prefix, but only where each read is verified on its own.
476
+ artifact.blobCache = artifact.verifyRows && options.blobCache ? options.blobCache : null;
466
477
  const staged = options.staged === true && artifact.microDims > 0;
467
478
  artifact.tier = 'full';
468
479
  artifact.fullyResident = Promise.resolve(artifact);
469
480
 
470
- if (!staged) {
471
- const resident = await readChecked(source, SKETCH_HEADER_BYTES, vectorsOffset - SKETCH_HEADER_BYTES, 'resident prefix', artifact.maxReadBytes);
481
+ // options.blobCache: the resident prefix is the bulk of a mount (46 MiB
482
+ // of a 649 MiB wiki pack) and is committed by residentSha256, so a
483
+ // cache keyed by that digest serves it to later processes. A hit is
484
+ // hash-checked inside blobCacheGet; only verified opens consult it,
485
+ // since verify:false has nothing to check a hit against. A cache hit
486
+ // also skips staging: the full tier is already local.
487
+ const cacheKey = verify && options.blobCache ? hexOf(header.subarray(56, 88)) : null;
488
+ const cached = cacheKey ? await blobCacheGet(options.blobCache, cacheKey, vectorsOffset - SKETCH_HEADER_BYTES) : null;
489
+ if (!staged || cached) {
490
+ const resident = cached || await readChecked(source, SKETCH_HEADER_BYTES, vectorsOffset - SKETCH_HEADER_BYTES, 'resident prefix', artifact.maxReadBytes);
472
491
  if (resident.byteLength !== vectorsOffset - SKETCH_HEADER_BYTES) {
473
492
  throw pikeletError(PIKELET_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact resident prefix is truncated');
474
493
  }
@@ -477,7 +496,9 @@ class PikeletSketchArtifact {
477
496
  const residentCopy = new Uint8Array(resident.byteLength);
478
497
  residentCopy.set(resident);
479
498
  artifact._adoptResident(residentCopy, count, sketchRowBytes, microRowBytes, microOffset);
480
- if (verify) await artifact._verifyFullResident(residentCopy, header);
499
+ if (cached) artifact.residentVerified = true;
500
+ else if (verify) await artifact._verifyFullResident(residentCopy, header);
501
+ if (cacheKey && !cached) blobCachePut(options.blobCache, cacheKey, residentCopy);
481
502
  return artifact;
482
503
  }
483
504
 
@@ -763,8 +784,37 @@ class PikeletSketchArtifact {
763
784
  }
764
785
  }
765
786
  if (!missing.length) return rows;
766
- missing.sort((a, b) => a - b);
767
787
  const { dim, rowsPerBlock, rowDigestBytes, pageBytes, blockBytes, vectorsOffset } = this;
788
+ // options.blobCache at open: a block's digest page is keyed by its
789
+ // page-table entry and each row by its digest in that page, so a row
790
+ // another process already fetched and verified is read from disk.
791
+ // Every hit is hash-checked against its key (blobCacheGet).
792
+ const cache = this.blobCache;
793
+ if (cache) {
794
+ const byBlock = new Map();
795
+ for (const id of missing) {
796
+ const b = Math.floor(id / rowsPerBlock);
797
+ if (!byBlock.has(b)) byBlock.set(b, []);
798
+ byBlock.get(b).push(id);
799
+ }
800
+ const still = [];
801
+ await mapLimit([...byBlock], 16, async ([b, ids]) => {
802
+ const page = await blobCacheGet(cache, hexOf(this.pageTable.subarray(b * 32, b * 32 + 32)), pageBytes);
803
+ if (!page) { still.push(...ids); return; }
804
+ for (const id of ids) {
805
+ const idx = id - b * rowsPerBlock;
806
+ const hit = await blobCacheGet(cache, hexOf(page.subarray(idx * rowDigestBytes, (idx + 1) * rowDigestBytes)), dim);
807
+ if (!hit) { still.push(id); continue; }
808
+ const row = new Uint8Array(hit);
809
+ rows.set(id, row);
810
+ this.cacheRow(id, row);
811
+ }
812
+ });
813
+ if (!still.length) return rows;
814
+ missing.length = 0;
815
+ missing.push(...still);
816
+ }
817
+ missing.sort((a, b) => a - b);
768
818
  const blocks = new Map();
769
819
  for (const id of missing) {
770
820
  const b = Math.floor(id / rowsPerBlock);
@@ -818,6 +868,7 @@ class PikeletSketchArtifact {
818
868
  throw pikeletError(PIKELET_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact row digest page failed hash verification', { block: b });
819
869
  }
820
870
  }
871
+ if (cache) blobCachePut(cache, hexOf(expected), page.slice());
821
872
  }
822
873
  for (const id of blocks.get(b).ids) {
823
874
  const idx = id - b * rowsPerBlock;
@@ -830,6 +881,7 @@ class PikeletSketchArtifact {
830
881
  throw pikeletError(PIKELET_ERROR_CODES.SNAPSHOT_INVALID, 'Sketch artifact row failed digest verification', { id });
831
882
  }
832
883
  }
884
+ if (cache) blobCachePut(cache, hexOf(slot), row);
833
885
  }
834
886
  if (rows.has(id)) rows.set(id, row);
835
887
  this.cacheRow(id, row);