@duckedup/nidus 0.1.1 → 0.53.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/README.md CHANGED
@@ -12,6 +12,10 @@ browser.
12
12
  npm install @duckedup/nidus
13
13
  ```
14
14
 
15
+ This package is versioned in lockstep with nidus itself: a given `@duckedup/nidus`
16
+ version is the client for the identically-numbered nidus release. Match the two and the
17
+ wire contract lines up.
18
+
15
19
  ## Connecting
16
20
 
17
21
  "Local vs remote" is just the base URL — point the client at a local `nidus serve`
@@ -32,9 +36,9 @@ const db = new NidusClient({
32
36
 
33
37
  ## Upserting and searching
34
38
 
35
- `attrs` accept plain JS values — strings, integers, booleans, string arrays, and
36
- `null` — and are normalized to nidus's typed values for you. (For an explicit type,
37
- use the `v.*` helpers.)
39
+ `attrs` accept plain JS values — strings, numbers, booleans, string arrays, `Date`s,
40
+ and `null` — and are normalized to nidus's typed values for you. (For an explicit
41
+ type, use the `v.*` helpers.)
38
42
 
39
43
  ```ts
40
44
  await db.createCollection("docs");
@@ -52,10 +56,40 @@ for (const hit of hits) {
52
56
  }
53
57
  ```
54
58
 
59
+ nidus has separate `Int` and `Float` attribute types and compares them same-type only,
60
+ but JS has one `number` and `1.0 === 1` — so a plain number becomes an `Int` when
61
+ `Number.isInteger` says so and a `Float` otherwise. That means a whole-numbered
62
+ measurement lands as an `Int` in whichever records it came out round, and a `Float`
63
+ range filter then skips exactly those. Pin such a field with `v.float`:
64
+
65
+ ```ts
66
+ import { v } from "@duckedup/nidus";
67
+
68
+ await db.upsert("docs", [
69
+ {
70
+ id: "d",
71
+ attrs: {
72
+ score: v.float(1), // a Float even though the value is whole
73
+ ratio: 0.75, // already a Float — not an integer
74
+ year: 2024, // an Int
75
+ seen: new Date(), // a DateTime: a UTC instant, epoch milliseconds
76
+ },
77
+ },
78
+ ]);
79
+ ```
80
+
81
+ A `DateTime` carries no timezone and has millisecond resolution; it decodes back to a
82
+ `Date`, so a decoded `attrs` map re-encodes to what it came from. `NaN` and `Infinity`
83
+ throw — JSON has no spelling for them. The Go and Python SDKs have the numeric types JS
84
+ lacks and decide from those instead, so a Python `2.0` or a Go `float64(2)` is a
85
+ `Float` where a bare `2` here is an `Int`.
86
+
55
87
  ## Filtering
56
88
 
57
89
  Build an AND-filter with the `f.*` helpers. Each predicate is a positive assertion
58
- about a present attribute (an absent key matches nothing).
90
+ about a present attribute (an absent key matches nothing). Comparisons are same-type
91
+ only, so an operand must encode to the attribute's type — `f.ge("score", v.float(2))`,
92
+ not `f.ge("score", 2)`, for a `Float` attribute.
59
93
 
60
94
  ```ts
61
95
  import { f } from "@duckedup/nidus";
@@ -72,10 +106,25 @@ const hits = await db.search({
72
106
  });
73
107
  ```
74
108
 
109
+ Beyond the comparisons there are text predicates — approximate, token-wise, and
110
+ regular-expression matching over a plain attribute (no full-text index required):
111
+
112
+ ```ts
113
+ f.fuzzy("title", "vecter store", 2); // within 2 Levenshtein edits (max 8)
114
+ f.containsAllTokens("body", "vector store"); // both tokens, any order
115
+ f.containsAnyToken("body", "vector store");
116
+ f.containsTokenSequence("body", "vector store"); // as a phrase, in order
117
+ f.regex("path", "src/.*\\.rs"); // anchored at both ends, like f.glob
118
+ ```
119
+
120
+ `f.regex` uses Rust's `regex` syntax, not JavaScript's: no backreferences and no
121
+ lookaround. Prefix it with `(?i)` for case-insensitive matching.
122
+
75
123
  ## Full-text and hybrid search
76
124
 
77
125
  ```ts
78
126
  await db.setFtsSchema("docs", ["body"]);
127
+ // Per-field tuning: await db.setFtsSchema("docs", [{ field: "body", k1: 1.5 }]);
79
128
 
80
129
  // BM25 text search
81
130
  const text = await db.textSearch({ field: "body", query: "vector store", topK: 10 });
@@ -89,6 +138,106 @@ const hybrid = await db.hybridSearch({
89
138
  });
90
139
  ```
91
140
 
141
+ A query can search several fields at once, each with its own text, by sending `clauses`
142
+ instead of the single field — folded by `combine`, `"Sum"` (a doc hitting title *and*
143
+ body outranks one hitting either) or `"Max"` (a long body cannot out-accumulate a
144
+ precise title match). Weight the two hybrid legs with `vectorWeight`/`textWeight`.
145
+
146
+ ```ts
147
+ const hits = await db.textSearch({
148
+ clauses: [
149
+ { field: "title", query: "rust" },
150
+ { field: "body", query: "async runtime" },
151
+ ],
152
+ combine: "Max",
153
+ topK: 10,
154
+ });
155
+ ```
156
+
157
+ ## Explaining and highlighting a hit
158
+
159
+ `explain` reports what each leg and each matched clause contributed; `highlight` returns
160
+ excerpts of the stored text (so it works even on a field the projection dropped). Both
161
+ land on `hit.annotations`, which is absent unless you asked for one of them.
162
+
163
+ ```ts
164
+ const hits = await db.hybridSearch({
165
+ vector: [0.1, 0.2, 0.3],
166
+ field: "body",
167
+ text: "vector store",
168
+ explain: true,
169
+ highlight: true, // or { maxFragments: 3, fragmentChars: 120 }
170
+ });
171
+
172
+ for (const { field, fragments } of hits[0]?.annotations?.highlights ?? []) {
173
+ for (const fragment of fragments) {
174
+ for (const span of fragment.spans) {
175
+ console.log(field, fragment.text.slice(...span));
176
+ }
177
+ }
178
+ }
179
+ ```
180
+
181
+ nidus reports a span as a **UTF-8 byte** range, but a JS string is indexed in UTF-16
182
+ code units — so a raw span slices the wrong text out of any non-ASCII excerpt. This SDK
183
+ converts them for you: `fragment.spans` are JS string indices, and `fragment.text.slice`
184
+ is the matched term. If you compare them against the raw HTTP response, expect the
185
+ numbers to differ wherever the excerpt is not ASCII.
186
+
187
+ ## Ranking, grouping, ordering, and aggregating
188
+
189
+ ```ts
190
+ // Prefer recent hits: subtract a penalty that halves every `scale` ms of age.
191
+ const recent = await db.search({
192
+ query: [0.1, 0.2, 0.3],
193
+ rankBy: {
194
+ decay: { field: "updated_at", origin: Date.now(), scale: 7 * 86_400_000 },
195
+ },
196
+ // …and keep at most 2 hits from any one file
197
+ limitPer: { field: "path", max: 2 },
198
+ });
199
+
200
+ // Sort a listing by an attribute instead of storage order
201
+ await db.list({ orderBy: { field: "updated_at", descending: true } });
202
+
203
+ // Count matches and sum attributes, without reading a single vector
204
+ const { count, sums } = await db.aggregate({
205
+ filter: f.and(f.eq("lang", "rust")),
206
+ sum: ["bytes"],
207
+ });
208
+ ```
209
+
210
+ Ages are measured back from `origin`, never the wall clock, so the same query against an
211
+ unchanged store ranks the same way twice. The penalty is *subtracted* from the score, so
212
+ it stays meaningful for a metric whose scores are negative or unbounded, and a record
213
+ with no usable timestamp is not penalized at all (`missing` defaults to `1`).
214
+
215
+ ## Remembering and recalling (text-native)
216
+
217
+ When the server is started with an embedder (`nidus serve --embed-provider …`), you
218
+ can send **text** and let the server embed it — no need to compute vectors client-side.
219
+ `remember` embeds and upserts; `recall` embeds the query and vector-searches.
220
+
221
+ ```ts
222
+ // Embed "the quick brown fox" and store it under id "a"
223
+ await db.remember("notes", "a", "the quick brown fox", { attrs: { tag: "x" } });
224
+
225
+ // Summarize first, then embed the summary (server also needs --summarize-provider).
226
+ // The stored record additionally carries `nidus.summary` and `nidus.source` attrs.
227
+ await db.remember("notes", "b", longArticle, { mode: "summarize" });
228
+
229
+ // Embed the query text and search, best-first (attrs decoded to plain JS values)
230
+ const hits = await db.recall("notes", "quick fox", {
231
+ topK: 5,
232
+ minScore: 0.2,
233
+ filter: f.and(f.eq("tag", "x")),
234
+ });
235
+ ```
236
+
237
+ Both throw a `NidusError` with status `400` if the server has no embedder configured
238
+ (the message names `--embed-provider`); `mode: "summarize"` without a summarizer is
239
+ likewise a `400`.
240
+
92
241
  ## Everything else
93
242
 
94
243
  ```ts
package/dist/index.cjs CHANGED
@@ -31,6 +31,49 @@ __export(index_exports, {
31
31
  });
32
32
  module.exports = __toCommonJS(index_exports);
33
33
 
34
+ // src/annotations.ts
35
+ function decodeAnnotations(a) {
36
+ const out = {};
37
+ if (a.vector) out.vector = a.vector;
38
+ if (a.text) out.text = a.text;
39
+ if (a.clauses) out.clauses = a.clauses;
40
+ if (a.highlights) out.highlights = a.highlights.map(decodeHighlight);
41
+ return out;
42
+ }
43
+ function decodeHighlight(h) {
44
+ return { field: h.field, fragments: h.fragments.map(decodeFragment) };
45
+ }
46
+ function decodeFragment(fr) {
47
+ return { text: fr.text, spans: toStringIndices(fr.text, fr.spans) };
48
+ }
49
+ function toStringIndices(text, spans) {
50
+ if (spans.length === 0 || isAscii(text)) return spans;
51
+ const index = byteToUnit(text);
52
+ const at = (b) => index[Math.min(Math.max(b, 0), index.length - 1)];
53
+ return spans.map(([start, end]) => [at(start), at(end)]);
54
+ }
55
+ function byteToUnit(text) {
56
+ const index = [];
57
+ let unit = 0;
58
+ for (const ch of text) {
59
+ for (let n = utf8Len(ch.codePointAt(0)); n > 0; n--) index.push(unit);
60
+ unit += ch.length;
61
+ }
62
+ index.push(unit);
63
+ return index;
64
+ }
65
+ function utf8Len(codePoint) {
66
+ if (codePoint < 128) return 1;
67
+ if (codePoint < 2048) return 2;
68
+ return codePoint < 65536 ? 3 : 4;
69
+ }
70
+ function isAscii(text) {
71
+ for (let i = 0; i < text.length; i++) {
72
+ if (text.charCodeAt(i) > 127) return false;
73
+ }
74
+ return true;
75
+ }
76
+
34
77
  // src/errors.ts
35
78
  var NidusError = class extends Error {
36
79
  /** The HTTP status code, or `0` for a transport/timeout failure (no response). */
@@ -59,6 +102,7 @@ var NidusError = class extends Error {
59
102
  };
60
103
 
61
104
  // src/values.ts
105
+ var TAGS = ["Str", "Int", "Bool", "List", "Float", "DateTime"];
62
106
  var v = {
63
107
  str: (s) => ({ Str: s }),
64
108
  int: (n) => {
@@ -67,15 +111,34 @@ var v = {
67
111
  }
68
112
  return { Int: n };
69
113
  },
114
+ float: (n) => {
115
+ if (typeof n !== "number" || !Number.isFinite(n)) {
116
+ throw new TypeError(`v.float expects a finite number, got ${n}`);
117
+ }
118
+ return { Float: n };
119
+ },
70
120
  bool: (b) => ({ Bool: b }),
71
121
  list: (items) => ({ List: items }),
122
+ /**
123
+ * A UTC instant, from a `Date` or a raw epoch-millisecond count. Milliseconds is
124
+ * the wire type, so there is no sub-millisecond precision and no timezone.
125
+ */
126
+ datetime: (when) => {
127
+ const ms = when instanceof Date ? when.getTime() : when;
128
+ if (!Number.isSafeInteger(ms)) {
129
+ throw new TypeError(
130
+ `v.datetime expects a valid Date or epoch ms, got ${when}`
131
+ );
132
+ }
133
+ return { DateTime: ms };
134
+ },
72
135
  /** The explicit `Null` value — set-but-empty, distinct from an absent key. */
73
136
  nil: () => "Null"
74
137
  };
75
138
  function isValue(x) {
76
139
  if (x === "Null") return true;
77
140
  if (typeof x !== "object" || x === null) return false;
78
- return "Str" in x || "Int" in x || "Bool" in x || "List" in x;
141
+ return TAGS.some((tag) => tag in x);
79
142
  }
80
143
  function encodeValue(input) {
81
144
  if (isValue(input)) return input;
@@ -86,8 +149,9 @@ function encodeValue(input) {
86
149
  case "boolean":
87
150
  return { Bool: input };
88
151
  case "number":
89
- return v.int(input);
152
+ return Number.isInteger(input) ? v.int(input) : v.float(input);
90
153
  case "object":
154
+ if (input instanceof Date) return v.datetime(input);
91
155
  if (Array.isArray(input)) {
92
156
  if (!input.every((e) => typeof e === "string")) {
93
157
  throw new TypeError("a List attribute must contain only strings");
@@ -112,6 +176,8 @@ function decodeValue(value) {
112
176
  if ("Int" in value) return value.Int;
113
177
  if ("Bool" in value) return value.Bool;
114
178
  if ("List" in value) return value.List;
179
+ if ("Float" in value) return value.Float;
180
+ if ("DateTime" in value) return new Date(value.DateTime);
115
181
  return value;
116
182
  }
117
183
  function decodeAttrs(attrs) {
@@ -231,10 +297,14 @@ var NidusClient = class {
231
297
  attrs: decodeAttrs(r.attrs)
232
298
  }));
233
299
  }
234
- /** Declare the full-text-indexed attribute fields for a collection. */
300
+ /**
301
+ * Declare the full-text-indexed attribute fields for a collection. A bare string
302
+ * takes the server's BM25/analyzer defaults; an {@link FtsField} object tunes `k1`,
303
+ * `b`, and the analyzer for that field alone.
304
+ */
235
305
  async setFtsSchema(name, fields) {
236
306
  await this.request("POST", `/collections/${enc(name)}/fts-schema`, {
237
- fields
307
+ fields: fields.map(encodeFtsField)
238
308
  });
239
309
  }
240
310
  // ── Search ──────────────────────────────────────────────────────────────
@@ -244,32 +314,54 @@ var NidusClient = class {
244
314
  query: opts.query,
245
315
  scope: opts.scope ?? [],
246
316
  top_k: opts.topK,
317
+ offset: opts.offset,
247
318
  min_score: opts.minScore,
248
- filter: opts.filter ?? []
319
+ filter: opts.filter ?? [],
320
+ exact: opts.exact,
321
+ include_attributes: opts.includeAttributes,
322
+ exclude_attributes: opts.excludeAttributes,
323
+ rank_by: encodeRankBy(opts.rankBy),
324
+ limit_per: opts.limitPer
249
325
  });
250
326
  }
251
- /** BM25 full-text search over one indexed field. */
327
+ /**
328
+ * BM25 full-text search over one indexed field, or over a `clauses` list folded by
329
+ * `combine` (`"Sum"` unless said otherwise). Naming the fields both ways is a `400`.
330
+ */
252
331
  textSearch(opts) {
253
332
  return this.searchRequest("/text-search", {
254
- field: opts.field,
255
- query: opts.query,
333
+ ...opts.clauses ? { clauses: opts.clauses, combine: opts.combine } : { field: opts.field, query: opts.query },
256
334
  scope: opts.scope ?? [],
257
335
  top_k: opts.topK,
336
+ offset: opts.offset,
258
337
  min_score: opts.minScore,
259
- filter: opts.filter ?? []
338
+ filter: opts.filter ?? [],
339
+ explain: opts.explain,
340
+ highlight: encodeHighlight(opts.highlight),
341
+ include_attributes: opts.includeAttributes,
342
+ exclude_attributes: opts.excludeAttributes,
343
+ rank_by: encodeRankBy(opts.rankBy),
344
+ limit_per: opts.limitPer
260
345
  });
261
346
  }
262
- /** Hybrid search: fuse a vector query and a BM25 text query via RRF. */
347
+ /**
348
+ * Hybrid search: fuse a vector query and a BM25 text query via RRF. The text leg takes
349
+ * the same single-field / `clauses` choice as {@link NidusClient.textSearch}.
350
+ */
263
351
  hybridSearch(opts) {
264
352
  return this.searchRequest("/hybrid-search", {
265
353
  vector: opts.vector,
266
- field: opts.field,
267
- text: opts.text,
354
+ ...opts.clauses ? { clauses: opts.clauses, combine: opts.combine } : { field: opts.field, text: opts.text },
268
355
  scope: opts.scope ?? [],
269
356
  top_k: opts.topK,
357
+ offset: opts.offset,
270
358
  filter: opts.filter ?? [],
271
359
  rrf_k: opts.rrfK,
272
- candidates: opts.candidates
360
+ candidates: opts.candidates,
361
+ explain: opts.explain,
362
+ highlight: encodeHighlight(opts.highlight),
363
+ vector_weight: opts.vectorWeight,
364
+ text_weight: opts.textWeight
273
365
  });
274
366
  }
275
367
  /** Metadata-only listing (no vector), paginated by `offset`/`limit`. */
@@ -278,6 +370,113 @@ var NidusClient = class {
278
370
  scope: opts.scope ?? [],
279
371
  offset: opts.offset,
280
372
  limit: opts.limit,
373
+ filter: opts.filter ?? [],
374
+ include_attributes: opts.includeAttributes,
375
+ exclude_attributes: opts.excludeAttributes,
376
+ order_by: opts.orderBy
377
+ });
378
+ }
379
+ /**
380
+ * Count the records matching a filter and sum the named attributes. Answered from the
381
+ * in-RAM index alone — no record is built and no vector is read.
382
+ */
383
+ async aggregate(opts = {}) {
384
+ const res = await this.request(
385
+ "POST",
386
+ "/aggregate",
387
+ prune({
388
+ scope: opts.scope ?? [],
389
+ filter: opts.filter ?? [],
390
+ sum: opts.sum ?? [],
391
+ group_by: opts.groupBy
392
+ })
393
+ );
394
+ return {
395
+ count: res.count,
396
+ sums: decodeAttrs(res.sums),
397
+ // Kept absent, not `undefined`, so an ungrouped answer is the shape it always was.
398
+ ...res.groups ? {
399
+ groups: res.groups.map((g) => ({
400
+ value: g.value === null ? null : decodeValue(g.value),
401
+ count: g.count,
402
+ sums: decodeAttrs(g.sums)
403
+ }))
404
+ } : {},
405
+ ...res.groups_truncated ? { groupsTruncated: true } : {}
406
+ };
407
+ }
408
+ /**
409
+ * Answer several vector queries in one round-trip (16 max). Returns one ranking per
410
+ * query in request order, or — with `opts.fuse` — a single array holding the one fused
411
+ * ranking, so the return shape is uniform either way.
412
+ *
413
+ * The server validates the whole batch before running any leg, so a malformed query
414
+ * fails the call rather than returning a partial answer that cannot be told apart.
415
+ */
416
+ async batchSearch(opts) {
417
+ const body = prune({
418
+ queries: opts.queries.map((q) => ({
419
+ query: q.query,
420
+ scope: q.scope ?? [],
421
+ top_k: q.topK,
422
+ offset: q.offset,
423
+ min_score: q.minScore,
424
+ filter: q.filter ?? [],
425
+ exact: q.exact,
426
+ include_attributes: q.includeAttributes,
427
+ exclude_attributes: q.excludeAttributes,
428
+ rank_by: encodeRankBy(q.rankBy),
429
+ limit_per: q.limitPer
430
+ })),
431
+ fuse: opts.fuse ? prune({
432
+ rrf_k: opts.fuse.rrfK,
433
+ weights: opts.fuse.weights,
434
+ top_k: opts.fuse.topK
435
+ }) : void 0
436
+ });
437
+ const res = await this.request(
438
+ "POST",
439
+ "/search/batch",
440
+ body
441
+ );
442
+ return (res.fused ? [res.fused] : res.results ?? []).map(
443
+ (hits) => hits.map((h) => this.decodeHit(h))
444
+ );
445
+ }
446
+ // ── Memory (text-native) ──────────────────────────────────────────────────
447
+ //
448
+ // Available only when `nidus serve` was started with an embedder
449
+ // (`--embed-provider …`); otherwise these answer `400`. The server embeds the
450
+ // text/query — the client only sends strings.
451
+ /**
452
+ * Embed `text` and upsert it under `id` in `collection` (idempotent on `id`).
453
+ * With `opts.mode === "summarize"` the server summarizes first, embeds the
454
+ * summary, and stamps `nidus.summary`/`nidus.source` attrs (requires the
455
+ * server to have a summarizer). `opts.attrs` accept plain JS values or `v.*`
456
+ * helpers; they are normalized for you.
457
+ */
458
+ async remember(collection, id, text, opts = {}) {
459
+ await this.request(
460
+ "POST",
461
+ `/collections/${enc(collection)}/remember`,
462
+ prune({
463
+ id,
464
+ text,
465
+ mode: opts.mode,
466
+ attrs: opts.attrs ? encodeAttrs(opts.attrs) : void 0
467
+ })
468
+ );
469
+ }
470
+ /**
471
+ * Embed `query` and vector-search `collection`, best-first (attrs decoded to
472
+ * plain JS values). Refused with a cross-model guard if the collection was
473
+ * written with a different embedder than the server's.
474
+ */
475
+ recall(collection, query, opts = {}) {
476
+ return this.searchRequest(`/collections/${enc(collection)}/recall`, {
477
+ query,
478
+ top_k: opts.topK,
479
+ min_score: opts.minScore,
281
480
  filter: opts.filter ?? []
282
481
  });
283
482
  }
@@ -294,12 +493,18 @@ var NidusClient = class {
294
493
  /** Run a search-family request and decode the resulting hits' attrs. */
295
494
  async searchRequest(path, body) {
296
495
  const hits = await this.request("POST", path, prune(body));
297
- return hits.map((h) => ({
496
+ return hits.map((h) => this.decodeHit(h));
497
+ }
498
+ /** One wire hit into a {@link Hit}. Shared so every search surface decodes identically. */
499
+ decodeHit(h) {
500
+ return {
298
501
  collection: h.collection,
299
502
  id: h.id,
300
503
  score: h.score,
301
- attrs: decodeAttrs(h.attrs)
302
- }));
504
+ attrs: decodeAttrs(h.attrs),
505
+ // Kept absent, not `undefined`, so an unannotated hit is the shape it always was.
506
+ ...h.annotations ? { annotations: decodeAnnotations(h.annotations) } : {}
507
+ };
303
508
  }
304
509
  /** Issue a request and parse a JSON body, mapping a non-2xx to {@link NidusError}. */
305
510
  async request(method, path, body) {
@@ -336,9 +541,42 @@ var NidusClient = class {
336
541
  }
337
542
  }
338
543
  };
544
+ function encodeRankBy(rank) {
545
+ if (!rank) return void 0;
546
+ const d = rank.decay;
547
+ return {
548
+ Decay: prune({
549
+ field: d.field,
550
+ origin: d.origin instanceof Date ? d.origin.getTime() : d.origin,
551
+ scale: d.scale,
552
+ decay: d.decay,
553
+ lambda: d.lambda,
554
+ missing: d.missing
555
+ })
556
+ };
557
+ }
558
+ function encodeHighlight(h) {
559
+ if (h === void 0 || h === false) return void 0;
560
+ if (h === true) return {};
561
+ return prune({
562
+ max_fragments: h.maxFragments,
563
+ fragment_chars: h.fragmentChars
564
+ });
565
+ }
339
566
  function enc(name) {
340
567
  return encodeURIComponent(name);
341
568
  }
569
+ function encodeFtsField(f2) {
570
+ if (typeof f2 === "string") return f2;
571
+ return prune({
572
+ field: f2.field,
573
+ k1: f2.k1,
574
+ b: f2.b,
575
+ language: f2.language,
576
+ ascii_folding: f2.asciiFolding,
577
+ max_token_len: f2.maxTokenLen
578
+ });
579
+ }
342
580
  function prune(body) {
343
581
  const out = {};
344
582
  for (const [k, val] of Object.entries(body)) {
@@ -367,6 +605,13 @@ var f = {
367
605
  }),
368
606
  /** `attrs[key]` is a `Str` matching the glob pattern (`*`, `?`, `[..]`). */
369
607
  glob: (key, pattern) => ({ Glob: [key, pattern] }),
608
+ /**
609
+ * {@link f.glob}, ignoring **ASCII** case on both sides — `"Src/*"` matches
610
+ * `"src/main.rs"`. Non-ASCII is not folded (`É` does not match `é`).
611
+ */
612
+ iglob: (key, pattern) => ({
613
+ IGlob: [key, pattern]
614
+ }),
370
615
  /** `attrs[key]` equals one of `values`. */
371
616
  in: (key, values) => ({
372
617
  In: [key, values.map(encodeValue)]
@@ -391,6 +636,56 @@ var f = {
391
636
  ge: (key, value) => ({
392
637
  Ge: [key, encodeValue(value)]
393
638
  }),
639
+ /** `attrs[key]` is a `List` containing `value` (whole-element, not substring). */
640
+ contains: (key, value) => ({
641
+ Contains: [key, encodeValue(value)]
642
+ }),
643
+ /** `attrs[key]` is a present `List` not containing `value`. */
644
+ notContains: (key, value) => ({
645
+ NotContains: [key, encodeValue(value)]
646
+ }),
647
+ /** `attrs[key]` is a `List` sharing at least one element with `values`. */
648
+ containsAny: (key, values) => ({
649
+ ContainsAny: [key, values.map(encodeValue)]
650
+ }),
651
+ /** Every sub-predicate holds. `all()` is `true`. */
652
+ all: (...preds) => ({ All: preds }),
653
+ /** At least one sub-predicate holds. `any()` is `false`. */
654
+ any: (...preds) => ({ Any: preds }),
655
+ /**
656
+ * The sub-predicate does not hold. Differs from {@link f.ne} on an absent key:
657
+ * `not(eq(k, v))` matches a record with no `k`, `ne(k, v)` does not.
658
+ */
659
+ not: (pred) => ({ Not: pred }),
660
+ /**
661
+ * `attrs[key]` is within `maxEdits` Levenshtein edits of `text`, ASCII-case-folded on
662
+ * both sides; a `List` matches if any element does. The only three-element predicate.
663
+ * A `maxEdits` above 8 is refused by the server, not clamped.
664
+ */
665
+ fuzzy: (key, text, maxEdits) => ({
666
+ Fuzzy: [key, text, maxEdits]
667
+ }),
668
+ /**
669
+ * Every token of `text` appears among `attrs[key]`'s tokens, in any order. Tokens are
670
+ * ASCII-case-folded runs of alphanumerics; a `List` matches if any single element does.
671
+ */
672
+ containsAllTokens: (key, text) => ({
673
+ ContainsAllTokens: [key, text]
674
+ }),
675
+ /** At least one token of `text` appears among `attrs[key]`'s tokens. Empty never matches. */
676
+ containsAnyToken: (key, text) => ({
677
+ ContainsAnyToken: [key, text]
678
+ }),
679
+ /** `text`'s tokens appear consecutively and in order — a phrase match. */
680
+ containsTokenSequence: (key, text) => ({
681
+ ContainsTokenSequence: [key, text]
682
+ }),
683
+ /**
684
+ * `attrs[key]` matches the regular expression, **anchored at both ends** like
685
+ * {@link f.glob} — `.*` opts back into a substring search, and `(?i)` into case folding.
686
+ * The syntax is Rust's `regex`, not JS's: no backreferences and no lookaround.
687
+ */
688
+ regex: (key, pattern) => ({ Regex: [key, pattern] }),
394
689
  /** Collect predicates into a {@link Filter} (purely sugar — they already AND). */
395
690
  and: (...preds) => preds
396
691
  };
@@ -405,6 +700,7 @@ var f = {
405
700
  f,
406
701
  v
407
702
  });
703
+ //! Decoding a hit's optional annotations — the opt-in "why did this match".
408
704
  //! Error type carrying the HTTP status the server reported.
409
705
  //! Ergonomic constructors and decoders for the externally-tagged `Value` wire type.
410
706
  //! `NidusClient` — a remote client over the `nidus serve` HTTP API.