pikelet-wasm 0.8.1 → 0.9.1

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,81 @@ 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`
11
+ ## pikelet-wasm 0.9.1 / pikelet 0.9.1 — 2026-09-30
12
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.
13
+ Fixes to the layered profile shipped in 0.9.0, headlined by a chain that
14
+ lost its abstention verdicts as soon as a layer deleted anything. Both
15
+ packages move to 0.9.1 together, and `pikelet` requires `pikelet-wasm`
16
+ ^0.9.1 so that `pikelet mcp` serves chains with the reader fixes.
16
17
 
17
- ### Security
18
+ ### Compatibility
18
19
 
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.
20
+ - **Layers from `append` and `rebase` now record a parent locator** when
21
+ the parent is a local file under the output's directory, so the same
22
+ inputs produce a layer with a different identity than under 0.9.0.
23
+ - **`--remove` and `--supersede` refuse ids they used to truncate**
24
+ (`1,2,3`, `12abc`, `1.5`, `1e3`); give one id per flag.
25
+ - **A chain-member location ending in `#` plus hex is read as a pin.** A
26
+ full 64-hex suffix is verified against the member's identity, and a
27
+ shorter one is refused; it is no longer treated as part of a filename.
30
28
 
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.
29
+ ### Fixed
43
30
 
44
- ## Unreleased
31
+ - **`pikelet-wasm` now exports `./package.json`.** `require.resolve('pikelet-wasm/package.json')`
32
+ and JSON imports of it threw `ERR_PACKAGE_PATH_NOT_EXPORTED`, which
33
+ tooling that reads the installed version relies on.
34
+ - **`pikelet append` parses `--remove` and `--supersede` ids strictly.**
35
+ `--remove 1,2,3` used to tombstone record 1 alone, and `--remove 12abc`
36
+ record 12, without an error; each flag now takes exactly one decimal id.
37
+ - **`#<sha256>` pins on chain members are enforced.** Every layered
38
+ producer's usage line advertised `--parent <file|url>[#identity]`, but
39
+ nothing parsed the pin: a local path failed as a missing file, and a URL
40
+ dropped the fragment and built on whatever the server returned. `--parent`,
41
+ `--head`, `--onto`, `--old-parent` and `--layer` now verify each pinned
42
+ member's manifest identity before opening anything, and refuse a
43
+ truncated pin.
44
+ - **`append` and `rebase` record a parent locator by default.** They wrote
45
+ one only when given `--parent-locator`, so a rebased layer could not be
46
+ mounted from its head alone and its next rebase needed `--old-parent`.
47
+ When the parent is a local file under the output's directory, its relative
48
+ path is now recorded. An explicit `--parent-locator` is validated up front
49
+ instead of being written as-is and refused by every reader.
50
+ - **A URL chain mounts from its head alone, and locator redirects are
51
+ confined.** Walking up from a URL head failed at the first hop ("a locator
52
+ needs the child's own location") because a range source carried no URL,
53
+ and a parent's URL would then have been read as a file path. The walk now
54
+ resolves each parent over HTTP. A parent named by a publisher's locator is
55
+ confined to the child's origin and directory at every redirect hop, as
56
+ 5.1.1 requires; lineage and host-resolver locations are the operator's
57
+ choice and are not confined. `httpRangeSource` gains `url` and a
58
+ `redirectGuard` option.
59
+ - **A tombstone-only layer no longer unscores its chain.** A layer that only
60
+ deletes records carries no vocabulary bloom, and the reader counted that
61
+ as a layer hiding its vocabulary, marking the whole chain
62
+ `drift-exceeded`: on the astro pack, deleting 3 of 3,255 records (drift
63
+ 0.0009 against a 0.20 limit) turned every query's verdict to `unscored`.
64
+ The rule now applies only to layers with records, and LAYERED_PROFILE.md
65
+ 4.5 and 5.5 say so. Tombstone-only layers already written with 0.9.0
66
+ score again with no rebuild.
67
+ - **`pikelet --help` documents `append`, `compact` and `rebase`**, including
68
+ `--supersede`, `--allow-drift`, `--max-depth` and pins.
69
+
70
+ ## pikelet-wasm 0.9.0 / pikelet 0.9.0 — 2026-09-29
71
+
72
+ Everything merged to `main` since 0.8.0 that the 0.8.1 and 0.8.2 point
73
+ releases below did not carry, headlined by the layered profile. Both
74
+ packages move to 0.9.0 together. Pre-1.0, this minor carries breaking
75
+ changes; they are listed first.
45
76
 
46
77
  ### Breaking / compatibility
47
78
 
79
+ - **`pikelet` 0.9.0 requires `pikelet-wasm` ^0.9.0.** Its new `append`,
80
+ `compact` and `rebase` commands and its chain mounts import
81
+ `pikelet-wasm/complete/layer-*.mjs` and `complete/format.mjs`, which no
82
+ 0.8.x exports; upgrade both together. A layer's profile string,
83
+ `pikelet-layer-v1`, is refused by every earlier reader by design: a layer
84
+ opened alone would serve a fraction of a corpus.
85
+
48
86
  - **The `PANCAKE_ENCODER_WEIGHTS_URL`, `PANCAKE_SEARCH_EMBED_WORKERS`,
49
87
  `PANCAKE_SEARCH_STUB_EMBEDDINGS`, and `PANCAKE_SEARCH_PYTHON`
50
88
  environment variable fallbacks are removed.** 0.7.0 introduced
@@ -59,6 +97,42 @@ backlog below. `pikelet` is unchanged at 0.8.1 and picks this up through its
59
97
  have; CI tests 18, 20 and 22. The `pikelet` CLI is unchanged at
60
98
  `>=20`.
61
99
 
100
+ ### Added
101
+
102
+ - **Layered search artifacts** (`LAYERED_PROFILE.md`, Draft 4). A pack can
103
+ be updated by appending a small `pikelet-layer-v1` file instead of
104
+ recompiling: a layer carries only its new records plus a cumulative
105
+ tombstone mask and supersession edges, and commits to its parent's
106
+ identity, so a chain's head identity pins every byte a query can touch.
107
+ `openPikeletChain` (`pikelet-wasm/complete/layer-reader.mjs`) mounts base
108
+ and layers, verifies every parent commitment and spec rule at mount, and
109
+ queries them as one corpus: masked candidate generation per tier,
110
+ chain-global BM25 statistics, one fusion, and the base's abstention fit
111
+ inherited until calibration drift passes the reader's limit (0.20 by
112
+ default), after which results ship `unscored`. Records carry the layer
113
+ that owns them; `record()` reports tombstoned records and their
114
+ successors; `citation()` resolves ids against any head on the mounted
115
+ history. A chain of local files mounts from its head alone through parent
116
+ locators; URL chains mount from a shelf `lineage`.
117
+ - **`pikelet append`** writes a layer over a base or chain: new documents
118
+ from `--source` (repeatable), deletions with `--remove`, and replacements
119
+ with `--supersede <id>=<path>`, reusing the base's own encoder, chunking
120
+ and vocabulary-bloom geometry. It refuses a layer that would push
121
+ calibration drift past the limit unless `--allow-drift` is given.
122
+ - **`pikelet compact`** collapses a chain into a fresh depth-0 base: live
123
+ records and quantized rows copied byte for byte, ids renumbered densely,
124
+ exact lexical statistics, and a refit abstention calibration (`--no-refit`
125
+ ships unscored). A lineage segment lets the compacted base translate
126
+ citations made against the chain.
127
+ - **`pikelet rebase`** moves a layer onto a different head (for instance a
128
+ fork sibling), replaying only what the layer itself did, with
129
+ `--on-conflict refuse|skip|keep-both` and `--on-foreign refuse|drop`.
130
+ - **MCP serves chains.** A shelf entry with a `lineage` mounts a chain,
131
+ pinned by its head identity; `list_packs` reports `chain`, `layers`,
132
+ `tombstones` and `calibrationStatus`, and counts live records; `get_record`
133
+ and search results carry `tombstoned`, `layer`, `supersededBy` and
134
+ `currentSuccessor`.
135
+
62
136
  ### Changed
63
137
 
64
138
  - **The repository is an npm workspace with the two packages under
@@ -249,6 +323,68 @@ backlog below. `pikelet` is unchanged at 0.8.1 and picks this up through its
249
323
  - Browser verification without a WebCrypto backend fails with a clear
250
324
  error instead of an obscure one.
251
325
 
326
+ ## 0.8.2 — `pikelet`
327
+
328
+ ### Fixed
329
+
330
+ - **`pikelet mcp` answers `initialize` without waiting for its packs to
331
+ mount.** The server mounted every pack before replying to the MCP
332
+ handshake, and a URL mount fetches the pack's resident prefix first: about
333
+ 5.6 s for the rust-book pack and 9.2 s for the 649 MiB Simple English
334
+ Wikipedia one. The published three-pack shelf took **10.2 s** to answer
335
+ `initialize`, past the point most MCP clients give up — so the documented
336
+ `mcp --shelf` command timed out for a new user.
337
+
338
+ Mounting now runs concurrently with the protocol loop. `initialize` returns
339
+ in about **0.6 s**; `tools/call` and `tools/list` await the mounts, so a
340
+ client gets a fast handshake and pays the mount cost on its first real call.
341
+ `tools/list` waits deliberately: it advertises the mounted pack names, and
342
+ answering early would name fewer packs than the server goes on to serve.
343
+
344
+ ### Documentation
345
+
346
+ - The install instructions lead with `npm install -g pikelet --omit=optional`.
347
+ Measured on a clean project, that is 2 packages / 1.8 MB / "found 0
348
+ vulnerabilities" against 82 packages / 257 MB / "6 vulnerabilities (5 high,
349
+ 1 critical)" — every advisory is inside `@xenova/transformers`, which only
350
+ the `create` scaffold path loads. The previous README put the flag in a
351
+ mid-paragraph aside and estimated its cost at "~140 MB".
352
+ - README carries an upgrade note for the `pikelet-wasm@0.8.1` heap-overflow
353
+ fix, which shipped without one.
354
+
355
+ ## 0.8.1 — `pikelet-wasm`
356
+
357
+ Security release, cut from `main` ahead of the rest of the backlog, which
358
+ shipped in 0.9.0. `pikelet` is unchanged at 0.8.1 and picks this up through its
359
+ `^0.8.0` dependency range.
360
+
361
+ ### Security
362
+
363
+ - **A `.pikelet` can no longer overflow the encoder's WASM heap through an
364
+ out-of-range `maxTokens`.** `complete/inline-transformer.mjs` sized the
365
+ kernel's ids and hidden-state buffers from the artifact's own encoder
366
+ declaration, clamped with `Math.min(declaration.maxTokens || 512, 512)` —
367
+ an upper bound only. A pack declaring `maxTokens: 1` made the windowing
368
+ arithmetic (`windowLen = maxSeq - 2`) negative, so `interior.slice(w *
369
+ windowLen, (w + 1) * windowLen)` returned nearly the whole token sequence
370
+ and the forward pass wrote past both allocations: a 60-token query wrote
371
+ about 92 KB into a 1,536-byte buffer. `maxTokens` is now validated as an
372
+ integer in `[8, 512]` at parse, before anything is allocated, `maxSeq` is
373
+ clamped on both sides, and both call sites assert the window fits.
374
+
375
+ This fired at **mount**, before any query: the reader awaits the encoder
376
+ for a non-deferred query-interp segment (and prefetches it otherwise), and
377
+ the declaration's own `testVectors` are verified after the buffers exist,
378
+ so a long test vector was itself a trigger. Every integrity check passed,
379
+ because the attacker authored the file and its digests bind their bytes.
380
+
381
+ Affects anyone who mounts a `.pikelet` they did not compile — the format's
382
+ intended use, since packs are distributed and range-read from URLs. The
383
+ write is confined to the encoder's WASM linear memory and does not reach
384
+ host JavaScript objects, which is why it is rated high rather than
385
+ critical. Upgrade to get the bound; there is no workaround short of not
386
+ mounting third-party packs.
387
+
252
388
  ## pikelet 0.8.1 — 2026-09-18
253
389
 
254
390
  - `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]));
@@ -34,6 +34,8 @@ export interface HttpRangeSourceStats {
34
34
 
35
35
  export interface HttpRangeSource extends CompleteRangeSource {
36
36
  stats: HttpRangeSourceStats;
37
+ /** The URL as given, before any redirect; a layer's parent locator resolves against it. */
38
+ url: string;
37
39
  init(): Promise<void>;
38
40
  }
39
41
 
@@ -55,6 +57,13 @@ export declare function httpRangeSource(url: string, options?: {
55
57
  * disables. CDNs rate-limit the parallel range bursts a query issues.
56
58
  */
57
59
  maxRetries?: number;
60
+ /**
61
+ * Called with each redirect target before it is followed; throw to refuse
62
+ * the hop. When set, range reads never auto-follow redirects. A chain walk
63
+ * sets it for a parent named by a layer's locator, confining every hop to
64
+ * the child's origin and directory (LAYERED_PROFILE.md 5.1.1).
65
+ */
66
+ redirectGuard?: (target: string) => void;
58
67
  }): HttpRangeSource;
59
68
 
60
69
  export interface CompleteQueryResult {