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 +59 -0
- package/complete/index.d.ts +9 -0
- package/complete/layer-chain.mjs +4 -2
- package/complete/layer-reader.mjs +28 -5
- package/complete/sources.mjs +27 -5
- package/package.json +3 -2
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
|
package/complete/index.d.ts
CHANGED
|
@@ -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 {
|
package/complete/layer-chain.mjs
CHANGED
|
@@ -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
|
|
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
|
|
89
|
-
kind:
|
|
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 || (
|
|
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
|
package/complete/sources.mjs
CHANGED
|
@@ -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
|
-
|
|
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),
|
|
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()),
|
|
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),
|
|
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.
|
|
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",
|