pikelet-wasm 0.9.0 → 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,6 +8,65 @@ 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.9.1 / pikelet 0.9.1 — 2026-09-30
12
+
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.
17
+
18
+ ### Compatibility
19
+
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.
28
+
29
+ ### Fixed
30
+
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
+
11
70
  ## pikelet-wasm 0.9.0 / pikelet 0.9.0 — 2026-09-29
12
71
 
13
72
  Everything merged to `main` since 0.8.0 that the 0.8.1 and 0.8.2 point
@@ -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 {
@@ -211,8 +211,10 @@ export function calibrationStatus({ drift, producerEnvelope, readerLimit, baseHa
211
211
  if (!baseHasFit) {
212
212
  return { status: 'none', drift, effectiveLimit, producerEnvelope: envelope, readerLimit };
213
213
  }
214
- // A layer whose calibration region is `none` while the base carries a fit
215
- // degrades the chain to unscored (4.5, 5.5).
214
+ // A layer WITH RECORDS whose calibration region is `none` while the base
215
+ // carries a fit degrades the chain to unscored (4.5, 5.5). The caller
216
+ // leaves tombstone-only layers out of everyLayerShipsBloom: they have no
217
+ // vocabulary for a bloom to carry.
216
218
  const status = (drift <= effectiveLimit && everyLayerShipsBloom) ? 'inherited' : 'drift-exceeded';
217
219
  return { status, drift, effectiveLimit, producerEnvelope: envelope, readerLimit };
218
220
  }
@@ -26,7 +26,8 @@
26
26
  // named by the caller and a published chain could not be mounted from its head.
27
27
 
28
28
  import { openPikeletFile, readChainMemberShell, readMemberManifest, base64Bytes, LEXICAL_CANDIDATES, LEXICAL_CUTOFF } from './index.mjs';
29
- import { resolveParentLocation } from './layer-locator.mjs';
29
+ import { resolveParentLocation, assertConfined } from './layer-locator.mjs';
30
+ import { httpRangeSource } from './sources.mjs';
30
31
  import { fuseCandidates, FUSION_DEFAULTS } from './fusion.mjs';
31
32
  import { parseTombstoneSegment, firstSupersetViolation } from './tombstones.mjs';
32
33
  import { validateAgainstParent, chainIngestDeclaration, validateIngestAgainstChain, LAYER_PROFILE, MAX_DEPTH } from './layer-manifest.mjs';
@@ -83,10 +84,15 @@ async function walkToBase(head, options) {
83
84
  }
84
85
  if (!peek.isLayer) return chain; // reached the base
85
86
  if (!peek.parent) throw new Error('a layer manifest carries no layer.parent (3.2)');
87
+ // A child is a URL whether it arrived as a range source or, one hop
88
+ // up, as the URL string a locator resolved to. Classifying every
89
+ // string as a file path sent that second hop to fileSource.
90
+ const childLocation = typeof cursor === 'string' ? cursor : (cursor.url ?? cursor.location ?? null);
91
+ const childIsFile = typeof cursor === 'string' && !isHttpUrl(cursor);
86
92
  const located = await resolveParentLocation(peek.parent, {
87
93
  lineage,
88
- childLocation: typeof cursor === 'string' ? cursor : (cursor.url ?? cursor.location ?? null),
89
- kind: typeof cursor === 'string' ? 'file' : 'url',
94
+ childLocation,
95
+ kind: childIsFile ? 'file' : 'url',
90
96
  resolveParents: options.resolveParents !== false,
91
97
  hostResolver: options.hostResolver || null,
92
98
  // A file-path locator needs realpath/dirname/join to confine
@@ -95,15 +101,27 @@ async function walkToBase(head, options) {
95
101
  // runtime-agnostic, so the Node implementation is supplied here
96
102
  // and only when a file path is actually being resolved -- a
97
103
  // browser or Worker mount is URL-only and never loads node:fs.
98
- fsops: options.fsops || (typeof cursor === 'string' ? await nodeFsops() : null),
104
+ fsops: options.fsops || (childIsFile ? await nodeFsops() : null),
99
105
  });
100
106
  expectIdentity = peek.parent.identity;
101
107
  cursor = located.location;
108
+ if (typeof cursor === 'string' && isHttpUrl(cursor)) {
109
+ // A publisher's locator is confined to the child's origin and
110
+ // directory, and so is every redirect on the way to it (5.1.1).
111
+ // A lineage listing or host resolver is the operator's choice and
112
+ // may name any origin, so its redirects are not confined.
113
+ const confineTo = located.via === 'locator' ? childLocation : null;
114
+ cursor = httpRangeSource(cursor, {
115
+ ...(confineTo ? { redirectGuard: (target) => assertConfined(target, confineTo) } : {}),
116
+ });
117
+ await cursor.init();
118
+ }
102
119
  chain.unshift(cursor);
103
120
  }
104
121
  throw new Error(`walking to the base exceeded the depth limit ${maxDepth} (2.7): a layer.parent cycle or an over-deep chain`);
105
122
  }
106
123
 
124
+ const isHttpUrl = (loc) => /^https?:\/\//i.test(loc);
107
125
  const describeLocation = (loc) => (typeof loc === 'string' ? loc : (loc?.url ?? loc?.location ?? '<source>'));
108
126
 
109
127
  // Lazily imported so a URL-only mount in a runtime without node:fs never
@@ -224,8 +242,13 @@ export async function openPikeletChain(members, options = {}) {
224
242
  if (inherited.bloomBytes && !baseBloomGeometry) {
225
243
  throw new Error(`layer at depth ${rel.depth} ships a vocabulary bloom but the base carries none whose geometry it could match (4.5)`);
226
244
  }
245
+ // A missing bloom degrades the chain because it hides a layer's
246
+ // vocabulary from known_frac (4.5). A tombstone-only layer has no
247
+ // records and so no vocabulary to hide: its `none` region is the
248
+ // empty bloom, not a gap. Counting it took a chain that deleted 3
249
+ // of 3,255 records to drift-exceeded, unscoring every query.
227
250
  if (inherited.bloomBytes) layerBlooms.push(inherited.bloomBytes);
228
- else everyLayerShipsBloom = false;
251
+ else if (!rel.tombstoneOnly) everyLayerShipsBloom = false;
229
252
 
230
253
  if (!rel.tombstoneOnly && tier.segments.has('lexical') !== baseHasLexical) {
231
254
  throw new Error(baseHasLexical
@@ -22,6 +22,13 @@ export function httpRangeSource(url, options = {}) {
22
22
  : (options.cacheKeyParam === null || options.cacheKeyParam === false) ? null
23
23
  : String(options.cacheKeyParam);
24
24
  let full = null;
25
+ // A parent reached through a publisher-supplied locator is confined to the
26
+ // child's origin and directory, and 5.1.1 applies that rule to EVERY
27
+ // redirect hop before it is followed. The guard throws to refuse a hop.
28
+ // With a guard set, reads never auto-follow: only the HEAD walk below,
29
+ // which checks each hop, may move the target.
30
+ const redirectGuard = typeof options.redirectGuard === 'function' ? options.redirectGuard : null;
31
+ const readInit = (headers) => (redirectGuard ? { headers, redirect: 'error' } : { headers });
25
32
 
26
33
  // Redirect memoization. A host that answers with a redirect (GitHub
27
34
  // release assets: github.com 302s every hit to a signed CDN URL) would
@@ -46,7 +53,13 @@ export function httpRangeSource(url, options = {}) {
46
53
  const probe = await fetch(target, { method: 'HEAD', redirect: 'manual' });
47
54
  const location = probe.headers.get('location');
48
55
  if (probe.status >= 300 && probe.status < 400 && location) {
49
- target = new URL(location, target).href;
56
+ const next = new URL(location, target).href;
57
+ if (redirectGuard) {
58
+ try { redirectGuard(next); } catch (err) {
59
+ throw Object.assign(new Error(`refused redirect to ${next}: ${err.message}`), { redirectRefused: true });
60
+ }
61
+ }
62
+ target = next;
50
63
  viaRedirect = true;
51
64
  stats.redirects += 1;
52
65
  continue;
@@ -64,7 +77,13 @@ export function httpRangeSource(url, options = {}) {
64
77
  }
65
78
  break;
66
79
  }
67
- } catch {
80
+ } catch (err) {
81
+ // A refused hop is a verdict, not a probe failure: falling back
82
+ // to the original URL would retry the same redirect.
83
+ if (redirectGuard && err?.redirectRefused) {
84
+ resolving = null;
85
+ throw err;
86
+ }
68
87
  // HEAD unsupported or blocked: fall back to auto-follow.
69
88
  target = url;
70
89
  viaRedirect = false;
@@ -78,6 +97,9 @@ export function httpRangeSource(url, options = {}) {
78
97
 
79
98
  return {
80
99
  stats,
100
+ // The location as given, before any redirect: what a child's parent
101
+ // locator resolves against (5.1.1).
102
+ url,
81
103
  size: undefined,
82
104
  preferredParallelism: options.preferredParallelism ?? 32,
83
105
  preferredGapBytes: options.preferredGapBytes ?? 16 * 1024,
@@ -108,18 +130,18 @@ export function httpRangeSource(url, options = {}) {
108
130
  const targetFor = ({ target, viaRedirect }) => (cacheKeyParam && !viaRedirect
109
131
  ? `${target}${target.includes('?') ? '&' : '?'}${cacheKeyParam}=${offset}-${end}`
110
132
  : target);
111
- let response = await fetch(targetFor(pinned), { headers });
133
+ let response = await fetch(targetFor(pinned), readInit(headers));
112
134
  if ((response.status === 401 || response.status === 403) && pinned.viaRedirect) {
113
135
  // The pinned signed URL expired; resolve a fresh one once
114
136
  // and retry. Concurrent reads share the re-resolution.
115
137
  if (resolvedTarget === pinned) resolvedTarget = null;
116
138
  stats.retries += 1;
117
- response = await fetch(targetFor(await resolveTarget()), { headers });
139
+ response = await fetch(targetFor(await resolveTarget()), readInit(headers));
118
140
  }
119
141
  for (let attempt = 0; RETRYABLE.has(response.status) && attempt < maxRetries; attempt++) {
120
142
  stats.retries += 1;
121
143
  await new Promise((resolve) => setTimeout(resolve, retryDelayMs(attempt, response)));
122
- response = await fetch(targetFor(resolvedTarget || pinned), { headers });
144
+ response = await fetch(targetFor(resolvedTarget || pinned), readInit(headers));
123
145
  }
124
146
 
125
147
  if (response.status === 206) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikelet-wasm",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
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",
@@ -101,7 +101,8 @@
101
101
  "./complete/layer-compact.mjs": {
102
102
  "default": "./complete/layer-compact.mjs"
103
103
  },
104
- "./engine.wasm": "./dist/engine.wasm"
104
+ "./engine.wasm": "./dist/engine.wasm",
105
+ "./package.json": "./package.json"
105
106
  },
106
107
  "files": [
107
108
  "src",