pikelet-wasm 0.8.1 → 0.9.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,43 +8,22 @@ 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
- ## 0.8.1 — `pikelet-wasm`
12
-
13
- Security release, cut from `main` ahead of the rest of the unreleased
14
- backlog below. `pikelet` is unchanged at 0.8.1 and picks this up through its
15
- `^0.8.0` dependency range.
16
-
17
- ### Security
11
+ ## pikelet-wasm 0.9.0 / pikelet 0.9.0 — 2026-09-29
18
12
 
19
- - **A `.pikelet` can no longer overflow the encoder's WASM heap through an
20
- out-of-range `maxTokens`.** `complete/inline-transformer.mjs` sized the
21
- kernel's ids and hidden-state buffers from the artifact's own encoder
22
- declaration, clamped with `Math.min(declaration.maxTokens || 512, 512)` —
23
- an upper bound only. A pack declaring `maxTokens: 1` made the windowing
24
- arithmetic (`windowLen = maxSeq - 2`) negative, so `interior.slice(w *
25
- windowLen, (w + 1) * windowLen)` returned nearly the whole token sequence
26
- and the forward pass wrote past both allocations: a 60-token query wrote
27
- about 92 KB into a 1,536-byte buffer. `maxTokens` is now validated as an
28
- integer in `[8, 512]` at parse, before anything is allocated, `maxSeq` is
29
- clamped on both sides, and both call sites assert the window fits.
30
-
31
- This fired at **mount**, before any query: the reader awaits the encoder
32
- for a non-deferred query-interp segment (and prefetches it otherwise), and
33
- the declaration's own `testVectors` are verified after the buffers exist,
34
- so a long test vector was itself a trigger. Every integrity check passed,
35
- because the attacker authored the file and its digests bind their bytes.
36
-
37
- Affects anyone who mounts a `.pikelet` they did not compile — the format's
38
- intended use, since packs are distributed and range-read from URLs. The
39
- write is confined to the encoder's WASM linear memory and does not reach
40
- host JavaScript objects, which is why it is rated high rather than
41
- critical. Upgrade to get the bound; there is no workaround short of not
42
- mounting third-party packs.
43
-
44
- ## Unreleased
13
+ Everything merged to `main` since 0.8.0 that the 0.8.1 and 0.8.2 point
14
+ releases below did not carry, headlined by the layered profile. Both
15
+ packages move to 0.9.0 together. Pre-1.0, this minor carries breaking
16
+ changes; they are listed first.
45
17
 
46
18
  ### Breaking / compatibility
47
19
 
20
+ - **`pikelet` 0.9.0 requires `pikelet-wasm` ^0.9.0.** Its new `append`,
21
+ `compact` and `rebase` commands and its chain mounts import
22
+ `pikelet-wasm/complete/layer-*.mjs` and `complete/format.mjs`, which no
23
+ 0.8.x exports; upgrade both together. A layer's profile string,
24
+ `pikelet-layer-v1`, is refused by every earlier reader by design: a layer
25
+ opened alone would serve a fraction of a corpus.
26
+
48
27
  - **The `PANCAKE_ENCODER_WEIGHTS_URL`, `PANCAKE_SEARCH_EMBED_WORKERS`,
49
28
  `PANCAKE_SEARCH_STUB_EMBEDDINGS`, and `PANCAKE_SEARCH_PYTHON`
50
29
  environment variable fallbacks are removed.** 0.7.0 introduced
@@ -59,6 +38,42 @@ backlog below. `pikelet` is unchanged at 0.8.1 and picks this up through its
59
38
  have; CI tests 18, 20 and 22. The `pikelet` CLI is unchanged at
60
39
  `>=20`.
61
40
 
41
+ ### Added
42
+
43
+ - **Layered search artifacts** (`LAYERED_PROFILE.md`, Draft 4). A pack can
44
+ be updated by appending a small `pikelet-layer-v1` file instead of
45
+ recompiling: a layer carries only its new records plus a cumulative
46
+ tombstone mask and supersession edges, and commits to its parent's
47
+ identity, so a chain's head identity pins every byte a query can touch.
48
+ `openPikeletChain` (`pikelet-wasm/complete/layer-reader.mjs`) mounts base
49
+ and layers, verifies every parent commitment and spec rule at mount, and
50
+ queries them as one corpus: masked candidate generation per tier,
51
+ chain-global BM25 statistics, one fusion, and the base's abstention fit
52
+ inherited until calibration drift passes the reader's limit (0.20 by
53
+ default), after which results ship `unscored`. Records carry the layer
54
+ that owns them; `record()` reports tombstoned records and their
55
+ successors; `citation()` resolves ids against any head on the mounted
56
+ history. A chain of local files mounts from its head alone through parent
57
+ locators; URL chains mount from a shelf `lineage`.
58
+ - **`pikelet append`** writes a layer over a base or chain: new documents
59
+ from `--source` (repeatable), deletions with `--remove`, and replacements
60
+ with `--supersede <id>=<path>`, reusing the base's own encoder, chunking
61
+ and vocabulary-bloom geometry. It refuses a layer that would push
62
+ calibration drift past the limit unless `--allow-drift` is given.
63
+ - **`pikelet compact`** collapses a chain into a fresh depth-0 base: live
64
+ records and quantized rows copied byte for byte, ids renumbered densely,
65
+ exact lexical statistics, and a refit abstention calibration (`--no-refit`
66
+ ships unscored). A lineage segment lets the compacted base translate
67
+ citations made against the chain.
68
+ - **`pikelet rebase`** moves a layer onto a different head (for instance a
69
+ fork sibling), replaying only what the layer itself did, with
70
+ `--on-conflict refuse|skip|keep-both` and `--on-foreign refuse|drop`.
71
+ - **MCP serves chains.** A shelf entry with a `lineage` mounts a chain,
72
+ pinned by its head identity; `list_packs` reports `chain`, `layers`,
73
+ `tombstones` and `calibrationStatus`, and counts live records; `get_record`
74
+ and search results carry `tombstoned`, `layer`, `supersededBy` and
75
+ `currentSuccessor`.
76
+
62
77
  ### Changed
63
78
 
64
79
  - **The repository is an npm workspace with the two packages under
@@ -249,6 +264,68 @@ backlog below. `pikelet` is unchanged at 0.8.1 and picks this up through its
249
264
  - Browser verification without a WebCrypto backend fails with a clear
250
265
  error instead of an obscure one.
251
266
 
267
+ ## 0.8.2 — `pikelet`
268
+
269
+ ### Fixed
270
+
271
+ - **`pikelet mcp` answers `initialize` without waiting for its packs to
272
+ mount.** The server mounted every pack before replying to the MCP
273
+ handshake, and a URL mount fetches the pack's resident prefix first: about
274
+ 5.6 s for the rust-book pack and 9.2 s for the 649 MiB Simple English
275
+ Wikipedia one. The published three-pack shelf took **10.2 s** to answer
276
+ `initialize`, past the point most MCP clients give up — so the documented
277
+ `mcp --shelf` command timed out for a new user.
278
+
279
+ Mounting now runs concurrently with the protocol loop. `initialize` returns
280
+ in about **0.6 s**; `tools/call` and `tools/list` await the mounts, so a
281
+ client gets a fast handshake and pays the mount cost on its first real call.
282
+ `tools/list` waits deliberately: it advertises the mounted pack names, and
283
+ answering early would name fewer packs than the server goes on to serve.
284
+
285
+ ### Documentation
286
+
287
+ - The install instructions lead with `npm install -g pikelet --omit=optional`.
288
+ Measured on a clean project, that is 2 packages / 1.8 MB / "found 0
289
+ vulnerabilities" against 82 packages / 257 MB / "6 vulnerabilities (5 high,
290
+ 1 critical)" — every advisory is inside `@xenova/transformers`, which only
291
+ the `create` scaffold path loads. The previous README put the flag in a
292
+ mid-paragraph aside and estimated its cost at "~140 MB".
293
+ - README carries an upgrade note for the `pikelet-wasm@0.8.1` heap-overflow
294
+ fix, which shipped without one.
295
+
296
+ ## 0.8.1 — `pikelet-wasm`
297
+
298
+ Security release, cut from `main` ahead of the rest of the backlog, which
299
+ shipped in 0.9.0. `pikelet` is unchanged at 0.8.1 and picks this up through its
300
+ `^0.8.0` dependency range.
301
+
302
+ ### Security
303
+
304
+ - **A `.pikelet` can no longer overflow the encoder's WASM heap through an
305
+ out-of-range `maxTokens`.** `complete/inline-transformer.mjs` sized the
306
+ kernel's ids and hidden-state buffers from the artifact's own encoder
307
+ declaration, clamped with `Math.min(declaration.maxTokens || 512, 512)` —
308
+ an upper bound only. A pack declaring `maxTokens: 1` made the windowing
309
+ arithmetic (`windowLen = maxSeq - 2`) negative, so `interior.slice(w *
310
+ windowLen, (w + 1) * windowLen)` returned nearly the whole token sequence
311
+ and the forward pass wrote past both allocations: a 60-token query wrote
312
+ about 92 KB into a 1,536-byte buffer. `maxTokens` is now validated as an
313
+ integer in `[8, 512]` at parse, before anything is allocated, `maxSeq` is
314
+ clamped on both sides, and both call sites assert the window fits.
315
+
316
+ This fired at **mount**, before any query: the reader awaits the encoder
317
+ for a non-deferred query-interp segment (and prefetches it otherwise), and
318
+ the declaration's own `testVectors` are verified after the buffers exist,
319
+ so a long test vector was itself a trigger. Every integrity check passed,
320
+ because the attacker authored the file and its digests bind their bytes.
321
+
322
+ Affects anyone who mounts a `.pikelet` they did not compile — the format's
323
+ intended use, since packs are distributed and range-read from URLs. The
324
+ write is confined to the encoder's WASM linear memory and does not reach
325
+ host JavaScript objects, which is why it is rated high rather than
326
+ critical. Upgrade to get the bound; there is no workaround short of not
327
+ mounting third-party packs.
328
+
252
329
  ## pikelet 0.8.1 — 2026-09-18
253
330
 
254
331
  - `pikelet` records `mcpName: "io.github.mcn92/pikelet"` and ships
package/README.md CHANGED
@@ -10,10 +10,26 @@ A `.pikelet` can carry the source text, semantic index, keyword index, query enc
10
10
 
11
11
  The `pikelet` CLI requires Node 20+; the `pikelet-wasm` library runs on Node 18+ (CI tests 18, 20, 22), browsers, and Cloudflare Workers.
12
12
 
13
+ > **Upgrade to `pikelet-wasm@0.8.1` if you mount packs you did not compile.**
14
+ > 0.8.0 and earlier sized the inline encoder's WASM buffers from the
15
+ > artifact's own `maxTokens` with an upper bound only, so a pack declaring an
16
+ > out-of-range value could overflow them — at mount, before any query, and
17
+ > with every integrity check passing, because the pack's author is the one who
18
+ > signed it. 0.8.1 validates the bound. Packs the toolchain produces were
19
+ > never affected. `pikelet@0.8.1` picks the fix up through its dependency
20
+ > range; see the CHANGELOG for the detail.
21
+
13
22
  ```bash
14
23
  npx pikelet compile --source ./docs --out docs.pikelet
15
24
  ```
16
25
 
26
+ Installing rather than using `npx`? Use `npm install -g pikelet
27
+ --omit=optional`. The optional dependency is `@xenova/transformers`, which
28
+ only the `create` scaffold path uses. Measured on a clean project, omitting it
29
+ takes the install from **82 packages / 257 MB / "6 vulnerabilities (5 high, 1
30
+ critical)"** to **2 packages / 1.8 MB / "found 0 vulnerabilities"** — the
31
+ advisories are all inside a dependency `compile` and `mcp` never load.
32
+
17
33
  ```text
18
34
  Ingested 3 docs -> 3 chunks
19
35
  Embedded 3/3 chunks with inline transformer
@@ -47,7 +63,7 @@ console.log(out.matchQuality, out.results[0]?.title);
47
63
  // 'strong' 'Snapshot restore'
48
64
  ```
49
65
 
50
- That's the whole loop. `compile` also takes a live URL (`--source https://docs.example.com`) instead of a directory. If you want a deployed search app — a Worker + UI, not a file — use `npx pikelet create` instead; see [`packages/pikelet/README.md`](packages/pikelet/README.md) for the full CLI reference and the tradeoffs between the two. `compile` never needs the scaffold path's `@xenova/transformers` dependency; skip its ~140 MB install with `npm install -g pikelet --omit=optional` if you only need `compile`/`mcp`.
66
+ That's the whole loop. `compile` also takes a live URL (`--source https://docs.example.com`) instead of a directory. If you want a deployed search app — a Worker + UI, not a file — use `npx pikelet create` instead; see [`packages/pikelet/README.md`](packages/pikelet/README.md) for the full CLI reference and the tradeoffs between the two. `create` is the one path that needs the optional `@xenova/transformers` dependency, which is why the install above omits it by default.
51
67
 
52
68
  Under the hood, the file is one container for everything a reader needs:
53
69
 
@@ -456,16 +456,29 @@ function selfQueriesFromSnapshot(graph, maxQueries) {
456
456
  // explicitly instead of misreading the corpus tables.
457
457
  export const PROFILE_V1 = 'pikelet-complete-v1';
458
458
  export const PROFILE_V2 = 'pikelet-complete-v2';
459
- export const FORMAT_VERSIONS = { [PROFILE_V1]: 1, [PROFILE_V2]: 2 };
459
+ // A layer is a complete-profile container under formatVersion 2 with its own
460
+ // manifest profile string (LAYERED_PROFILE.md 4.1). A reader that predates the
461
+ // layered profile sees the string under formatVersion 2 and refuses with its
462
+ // existing "unsupported profile" error, which is the intended behavior: a layer
463
+ // opened alone would silently serve a fraction of a corpus.
464
+ export const PROFILE_LAYER = 'pikelet-layer-v1';
465
+ export const FORMAT_VERSIONS = { [PROFILE_V1]: 1, [PROFILE_V2]: 2, [PROFILE_LAYER]: 2 };
460
466
 
461
467
  export function assemblePikeletFile(manifestFields, segments, outPath) {
462
468
  const formatVersion = FORMAT_VERSIONS[manifestFields.profile];
463
469
  if (!formatVersion) {
464
470
  throw new Error(`manifest.profile must be ${PROFILE_V1} or ${PROFILE_V2}, got ${manifestFields.profile}`);
465
471
  }
472
+ const isLayer = manifestFields.profile === PROFILE_LAYER;
466
473
  const layout = manifestFields.corpus?.layout;
467
- if (formatVersion === 2 && layout !== CORPUS_LAYOUT_V2) {
468
- throw new Error(`${PROFILE_V2} requires manifest.corpus from buildCorpusSegment() (layout ${CORPUS_LAYOUT_V2})`);
474
+ // A tombstone-only layer (4.1) carries a corpus object with records 0 and
475
+ // MUST omit the layout fields, because it has no corpus segment to describe.
476
+ const tombstoneOnlyLayer = isLayer && manifestFields.corpus?.records === 0;
477
+ if (formatVersion === 2 && !tombstoneOnlyLayer && layout !== CORPUS_LAYOUT_V2) {
478
+ throw new Error(`${manifestFields.profile} requires manifest.corpus from buildCorpusSegment() (layout ${CORPUS_LAYOUT_V2})`);
479
+ }
480
+ if (tombstoneOnlyLayer && layout !== undefined) {
481
+ throw new Error('a tombstone-only layer must omit corpus.layout');
469
482
  }
470
483
  if (formatVersion === 1 && layout !== undefined) {
471
484
  throw new Error(`${PROFILE_V1} carries corpus layout v1 (buildCorpusSegmentFromBuffers); got layout ${layout}`);
@@ -479,7 +492,7 @@ export function assemblePikeletFile(manifestFields, segments, outPath) {
479
492
  // sketch's own integrity chain to the complete identity without forcing a
480
493
  // whole-segment read at open (the index segment stays lazy).
481
494
  let indexCommitment;
482
- if (formatVersion >= 2) {
495
+ if (formatVersion >= 2 && !tombstoneOnlyLayer) {
483
496
  const index = segments.find((s) => s.kind === 'index');
484
497
  if (!index || index.bytes.length < 256) {
485
498
  throw new Error('index segment must carry a 256-byte sketch header');
@@ -4,5 +4,10 @@
4
4
  export const MAGIC = 0x31465350; // "PSF1"
5
5
  export const HEADER_BYTES = 64;
6
6
  export const TABLE_ENTRY_BYTES = 48;
7
- export const KINDS = { index: 1, corpus: 2, 'query-interp': 3, evaluation: 4, lexical: 5 };
7
+ // Kinds 6 and 7 belong to the layered profile (LAYERED_PROFILE.md 4.2): a
8
+ // layer carries exactly one tombstones segment, and a base compacted from a
9
+ // chain carries at most one lineage segment. They are registered here so the
10
+ // shared table has one definition; a complete-profile artifact never has
11
+ // either, and the required-segment tables stay per-profile.
12
+ export const KINDS = { index: 1, corpus: 2, 'query-interp': 3, evaluation: 4, lexical: 5, tombstones: 6, lineage: 7 };
8
13
  export const KIND_NAMES = Object.fromEntries(Object.entries(KINDS).map(([name, kind]) => [kind, name]));