@gmickel/gno 2.3.2 → 2.4.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.
Files changed (88) hide show
  1. package/README.md +24 -4
  2. package/assets/skill/SKILL.md +30 -0
  3. package/assets/skill/cli-reference.md +12 -0
  4. package/assets/skill/examples.md +15 -0
  5. package/assets/skill/mcp-reference.md +16 -0
  6. package/assets/spa-production.json.gz +0 -0
  7. package/browser-extension/artifacts/{gno-browser-clipper-v2.3.2.zip → gno-browser-clipper-v2.4.0.zip} +0 -0
  8. package/browser-extension/artifacts/gno-browser-clipper-v2.4.0.zip.sha256 +1 -0
  9. package/browser-extension/dist/manifest.json +1 -1
  10. package/package.json +1 -1
  11. package/spec/cli.md +30 -0
  12. package/spec/db/schema.sql +2 -0
  13. package/spec/evals.md +48 -0
  14. package/spec/mcp.md +12 -0
  15. package/spec/output-schemas/ask.schema.json +12 -0
  16. package/spec/output-schemas/context-capsule-v1.schema.json +205 -32
  17. package/spec/output-schemas/context-capsule-verification.schema.json +32 -9
  18. package/spec/output-schemas/evidence-fixtures.schema.json +176 -0
  19. package/spec/output-schemas/evidence-report.schema.json +322 -0
  20. package/spec/output-schemas/evidence-run.schema.json +277 -0
  21. package/spec/output-schemas/metadata-predicate.schema.json +460 -0
  22. package/spec/output-schemas/retrieval-trace-filters.schema.json +13 -4
  23. package/spec/output-schemas/search-results.schema.json +12 -0
  24. package/spec/output-schemas/status.schema.json +37 -6
  25. package/src/app/context-runtime-contract.ts +33 -2
  26. package/src/app/context-runtime-input.ts +17 -0
  27. package/src/app/context-runtime-types.ts +2 -0
  28. package/src/app/context-runtime.ts +27 -3
  29. package/src/app/context-surface.ts +2 -0
  30. package/src/app/verified-ask.ts +1 -0
  31. package/src/cli/commands/ask.ts +1 -0
  32. package/src/cli/commands/context-build.ts +1 -0
  33. package/src/cli/commands/search.ts +1 -0
  34. package/src/cli/commands/status.ts +10 -0
  35. package/src/cli/options.ts +19 -0
  36. package/src/cli/program.ts +12 -0
  37. package/src/converters/types.ts +2 -0
  38. package/src/core/context-capsule-retrieval-schema.ts +8 -0
  39. package/src/core/context-capsule-schema.ts +47 -3
  40. package/src/core/context-capsule-validation.ts +14 -2
  41. package/src/core/context-capsule-verification.ts +1 -0
  42. package/src/core/context-capsule.ts +16 -2
  43. package/src/core/context-compiler.ts +3 -0
  44. package/src/core/record-metadata.ts +1 -1
  45. package/src/core/retrieval-replay-candidate.ts +5 -0
  46. package/src/core/retrieval-trace-filter-normalization.ts +4 -0
  47. package/src/core/retrieval-trace-request.ts +1 -0
  48. package/src/core/retrieval-trace-session.ts +5 -1
  49. package/src/core/retrieval-trace.ts +2 -0
  50. package/src/core/typed-metadata.ts +224 -0
  51. package/src/ingestion/record-adapter-canonical.ts +13 -2
  52. package/src/ingestion/record-container.ts +9 -0
  53. package/src/ingestion/sync.ts +11 -1
  54. package/src/ingestion/typed-metadata.ts +55 -0
  55. package/src/mcp/tools/ask.ts +10 -2
  56. package/src/mcp/tools/context.ts +1 -0
  57. package/src/mcp/tools/index.ts +12 -1
  58. package/src/mcp/tools/query.ts +13 -0
  59. package/src/mcp/tools/search.ts +9 -0
  60. package/src/mcp/tools/status.ts +4 -0
  61. package/src/mcp/tools/vsearch.ts +9 -0
  62. package/src/pipeline/diagnose.ts +6 -0
  63. package/src/pipeline/filters.ts +65 -0
  64. package/src/pipeline/graph-retrieval.ts +9 -1
  65. package/src/pipeline/hybrid.ts +22 -2
  66. package/src/pipeline/search.ts +11 -0
  67. package/src/pipeline/types.ts +7 -1
  68. package/src/pipeline/vsearch.ts +21 -1
  69. package/src/sdk/client.ts +29 -1
  70. package/src/sdk/index.ts +4 -0
  71. package/src/sdk/types.ts +7 -0
  72. package/src/serve/context-capsule.ts +1 -0
  73. package/src/serve/public/app.tsx +1 -1
  74. package/src/serve/public/components/MetadataFilter.tsx +186 -0
  75. package/src/serve/public/globals.built.css +1 -1
  76. package/src/serve/public/lib/metadata-filter.ts +28 -0
  77. package/src/serve/public/lib/retrieval-filters.ts +3 -0
  78. package/src/serve/public/pages/Ask.tsx +48 -3
  79. package/src/serve/public/pages/DocView.tsx +33 -0
  80. package/src/serve/public/pages/Search.tsx +44 -1
  81. package/src/serve/routes/api.ts +50 -0
  82. package/src/store/migrations/030-typed-metadata.ts +13 -0
  83. package/src/store/migrations/index.ts +2 -0
  84. package/src/store/sqlite/adapter.ts +55 -4
  85. package/src/store/sqlite/eligibility.ts +12 -0
  86. package/src/store/sqlite/metadata-predicate.ts +71 -0
  87. package/src/store/types.ts +11 -0
  88. package/browser-extension/artifacts/gno-browser-clipper-v2.3.2.zip.sha256 +0 -1
@@ -0,0 +1,28 @@
1
+ import { ZodError } from "zod";
2
+
3
+ import {
4
+ normalizeMetadataPredicate,
5
+ type MetadataPredicate,
6
+ } from "../../../core/typed-metadata";
7
+
8
+ export function parseMetadataFilter(text: string): {
9
+ filter?: MetadataPredicate;
10
+ error?: string;
11
+ } {
12
+ if (!text.trim()) return {};
13
+ try {
14
+ return { filter: normalizeMetadataPredicate(JSON.parse(text)) };
15
+ } catch (error) {
16
+ if (error instanceof ZodError) {
17
+ const issue = error.issues[0];
18
+ const path = issue?.path.length ? `.${issue.path.join(".")}` : "";
19
+ return {
20
+ error: `filter${path}: ${issue?.message ?? "Invalid predicate"}. Check the operator, key, and JSON value type.`,
21
+ };
22
+ }
23
+ return {
24
+ error:
25
+ "filter: Invalid JSON. Use quoted strings, finite numbers, booleans, or arrays of one type.",
26
+ };
27
+ }
28
+ }
@@ -8,6 +8,7 @@ export interface QueryModeEntry {
8
8
  }
9
9
 
10
10
  export interface RetrievalFiltersState {
11
+ filter?: string;
11
12
  collection: string;
12
13
  intent: string;
13
14
  candidateLimit: string;
@@ -123,6 +124,7 @@ export function parseFiltersFromSearch(
123
124
  const tags = parseTagsCsv(tagsAll ?? tagsAny ?? "");
124
125
 
125
126
  return {
127
+ filter: params.get("filter") ?? defaults.filter ?? "",
126
128
  collection: params.get("collection") ?? defaults.collection ?? "",
127
129
  intent: params.get("intent") ?? defaults.intent ?? "",
128
130
  candidateLimit:
@@ -151,6 +153,7 @@ export function applyFiltersToUrl(
151
153
  }
152
154
  };
153
155
 
156
+ setOrDelete("filter", filters.filter ?? "");
154
157
  setOrDelete("collection", filters.collection);
155
158
  setOrDelete("intent", filters.intent);
156
159
  setOrDelete("candidateLimit", filters.candidateLimit);
@@ -25,6 +25,7 @@ import {
25
25
  type AskVerification,
26
26
  AskVerificationPanel,
27
27
  } from "../components/AskVerificationPanel";
28
+ import { MetadataFilter } from "../components/MetadataFilter";
28
29
  import {
29
30
  ThoroughnessSelector,
30
31
  type Thoroughness,
@@ -48,6 +49,7 @@ import {
48
49
  import { Textarea } from "../components/ui/textarea";
49
50
  import { apiFetch } from "../hooks/use-api";
50
51
  import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
52
+ import { parseMetadataFilter } from "../lib/metadata-filter";
51
53
  import {
52
54
  parseTagsCsv,
53
55
  type QueryModeEntry,
@@ -92,6 +94,7 @@ interface AskResponse {
92
94
  citations?: Citation[];
93
95
  results: SearchResult[];
94
96
  meta: {
97
+ warnings?: { code: string; message: string }[];
95
98
  expanded: boolean;
96
99
  reranked: boolean;
97
100
  vectorsUsed: boolean;
@@ -200,14 +203,21 @@ export default function Ask({ navigate }: PageProps) {
200
203
  const [activePreset, setActivePreset] = useState("slim-tuned");
201
204
  const [verify, setVerify] = useState(false);
202
205
 
203
- const [showAdvanced, setShowAdvanced] = useState(false);
204
- const [selectedCollection, setSelectedCollection] = useState("");
206
+ const [showAdvanced, setShowAdvanced] = useState(
207
+ Boolean(new URLSearchParams(window.location.search).get("filter"))
208
+ );
209
+ const [selectedCollection, setSelectedCollection] = useState(
210
+ new URLSearchParams(window.location.search).get("collection") ?? ""
211
+ );
205
212
  const [intent, setIntent] = useState("");
206
213
  const [candidateLimit, setCandidateLimit] = useState("");
207
214
  const [exclude, setExclude] = useState("");
208
215
  const [since, setSince] = useState("");
209
216
  const [until, setUntil] = useState("");
210
217
  const [category, setCategory] = useState("");
218
+ const [metadataFilter, setMetadataFilter] = useState(
219
+ new URLSearchParams(window.location.search).get("filter") ?? ""
220
+ );
211
221
  const [author, setAuthor] = useState("");
212
222
  const [tagMode, setTagMode] = useState<TagMode>("any");
213
223
  const [tagsInput, setTagsInput] = useState("");
@@ -255,6 +265,16 @@ export default function Ask({ navigate }: PageProps) {
255
265
  void bootstrap();
256
266
  }, []);
257
267
 
268
+ useEffect(() => {
269
+ const url = new URL(window.location.href);
270
+ if (selectedCollection.trim())
271
+ url.searchParams.set("collection", selectedCollection.trim());
272
+ else url.searchParams.delete("collection");
273
+ if (metadataFilter) url.searchParams.set("filter", metadataFilter);
274
+ else url.searchParams.delete("filter");
275
+ window.history.replaceState({}, "", url.toString());
276
+ }, [metadataFilter, selectedCollection]);
277
+
258
278
  const cycleThoroughness = useCallback(() => {
259
279
  setThoroughness((current) => {
260
280
  if (!hybridAvailable) {
@@ -262,7 +282,7 @@ export default function Ask({ navigate }: PageProps) {
262
282
  }
263
283
  const currentIdx = THOROUGHNESS_ORDER.indexOf(current);
264
284
  const nextIdx = (currentIdx + 1) % THOROUGHNESS_ORDER.length;
265
- return THOROUGHNESS_ORDER[nextIdx];
285
+ return THOROUGHNESS_ORDER[nextIdx] ?? current;
266
286
  });
267
287
  }, [hybridAvailable]);
268
288
 
@@ -304,12 +324,19 @@ export default function Ask({ navigate }: PageProps) {
304
324
  if (!query.trim()) {
305
325
  return;
306
326
  }
327
+ const parsedFilter = parseMetadataFilter(metadataFilter);
328
+ if (parsedFilter.error) {
329
+ setQueryModeError(parsedFilter.error);
330
+ setShowAdvanced(true);
331
+ return;
332
+ }
307
333
  if (!structuredQueryState.ok) {
308
334
  setQueryModeError(structuredQueryState.error.message);
309
335
  setShowAdvanced(true);
310
336
  return;
311
337
  }
312
338
 
339
+ setQueryModeError(null);
313
340
  const entryId = crypto.randomUUID();
314
341
  const currentQuery = query.trim();
315
342
 
@@ -334,6 +361,7 @@ export default function Ask({ navigate }: PageProps) {
334
361
  verify,
335
362
  };
336
363
 
364
+ if (parsedFilter.filter) requestBody.filter = parsedFilter.filter;
337
365
  if (selectedCollection) {
338
366
  requestBody.collection = selectedCollection;
339
367
  }
@@ -393,6 +421,7 @@ export default function Ask({ navigate }: PageProps) {
393
421
  );
394
422
  },
395
423
  [
424
+ metadataFilter,
396
425
  author,
397
426
  candidateLimit,
398
427
  activePreset,
@@ -428,6 +457,7 @@ export default function Ask({ navigate }: PageProps) {
428
457
  setUntil("");
429
458
  setCategory("");
430
459
  setAuthor("");
460
+ setMetadataFilter("");
431
461
  setTagsInput("");
432
462
  setTagMode("any");
433
463
  setQueryModes([]);
@@ -454,6 +484,7 @@ export default function Ask({ navigate }: PageProps) {
454
484
  since ? `since:${since}` : null,
455
485
  until ? `until:${until}` : null,
456
486
  category.trim() ? `category:${category.trim()}` : null,
487
+ metadataFilter.trim() ? "custom metadata" : null,
457
488
  author.trim() ? `author:${author.trim()}` : null,
458
489
  parseTagsCsv(tagsInput).length > 0
459
490
  ? `${tagMode}:${parseTagsCsv(tagsInput).join(",")}`
@@ -687,6 +718,11 @@ export default function Ask({ navigate }: PageProps) {
687
718
  </div>
688
719
  </div>
689
720
 
721
+ <MetadataFilter
722
+ onChange={setMetadataFilter}
723
+ value={metadataFilter}
724
+ />
725
+
690
726
  <div className="flex flex-wrap items-center gap-2">
691
727
  <span className="text-muted-foreground text-xs">
692
728
  Tag match:
@@ -857,6 +893,15 @@ export default function Ask({ navigate }: PageProps) {
857
893
 
858
894
  {entry.response && (
859
895
  <>
896
+ {entry.response.meta.warnings?.map((warning) => (
897
+ <p
898
+ className="rounded border border-secondary/30 bg-secondary/10 p-3 text-sm"
899
+ key={warning.code}
900
+ role="status"
901
+ >
902
+ {warning.message}
903
+ </p>
904
+ ))}
860
905
  {entry.response.answer && (
861
906
  <div className="prose prose-sm prose-invert max-w-none rounded-lg border border-border/30 bg-card/60 p-5 shadow-[0_0_30px_-10px_hsl(var(--primary)/0.08)]">
862
907
  <p className="whitespace-pre-wrap leading-relaxed">
@@ -29,6 +29,7 @@ import {
29
29
  useState,
30
30
  } from "react";
31
31
 
32
+ import type { TypedMetadata } from "../../../core/typed-metadata";
32
33
  import type { PdfFallbackReason } from "../lib/pdf";
33
34
 
34
35
  import {
@@ -167,6 +168,8 @@ interface PageProps {
167
168
  }
168
169
 
169
170
  interface DocData {
171
+ typedMetadata?: TypedMetadata | null;
172
+ metadataError?: string | null;
170
173
  docid: string;
171
174
  uri: string;
172
175
  title: string | null;
@@ -1326,12 +1329,41 @@ export default function DocView({ navigate }: PageProps) {
1326
1329
  </nav>
1327
1330
  );
1328
1331
 
1332
+ const storedMetadata =
1333
+ doc?.metadataError ||
1334
+ (doc?.typedMetadata && Object.keys(doc.typedMetadata).length > 0) ? (
1335
+ <section
1336
+ aria-label="Indexed custom metadata"
1337
+ className="space-y-2 border-border/20 border-t px-3 py-3"
1338
+ >
1339
+ <h3 className="font-mono text-muted-foreground text-xs">
1340
+ Indexed custom metadata
1341
+ </h3>
1342
+ {doc.metadataError ? (
1343
+ <p className="break-words text-destructive text-xs" role="alert">
1344
+ {doc.metadataError}. Correct the source and sync again; this
1345
+ document is excluded from custom metadata filters.
1346
+ </p>
1347
+ ) : (
1348
+ <dl className="space-y-1 text-xs">
1349
+ {Object.entries(doc.typedMetadata ?? {}).map(([key, value]) => (
1350
+ <div key={key}>
1351
+ <dt className="break-all text-muted-foreground">{key}</dt>
1352
+ <dd className="break-all font-mono">{JSON.stringify(value)}</dd>
1353
+ </div>
1354
+ ))}
1355
+ </dl>
1356
+ )}
1357
+ </section>
1358
+ ) : null;
1359
+
1329
1360
  /** Left rail — metadata + outline */
1330
1361
  const renderDocumentFactsRail = () => (
1331
1362
  <nav
1332
1363
  aria-label="Document facts"
1333
1364
  className="w-full min-w-0 max-w-full space-y-0 overflow-x-hidden"
1334
1365
  >
1366
+ {storedMetadata}
1335
1367
  {/* Frontmatter + tags */}
1336
1368
  {(hasFrontmatter || showStandaloneTags) && (
1337
1369
  <>
@@ -1623,6 +1655,7 @@ export default function DocView({ navigate }: PageProps) {
1623
1655
  </div>
1624
1656
  </div>
1625
1657
 
1658
+ {storedMetadata}
1626
1659
  {(hasFrontmatter || showStandaloneTags) && (
1627
1660
  <div className="rounded-lg border border-border/40 bg-muted/10 p-2.5">
1628
1661
  {hasFrontmatter && (
@@ -7,11 +7,12 @@ import {
7
7
  SlidersHorizontal,
8
8
  XIcon,
9
9
  } from "lucide-react";
10
- import { useCallback, useEffect, useMemo, useState } from "react";
10
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11
11
 
12
12
  import { resolveDepthPolicy } from "../../../core/depth-policy";
13
13
  import { normalizeStructuredQueryInput } from "../../../core/structured-query";
14
14
  import { Loader } from "../components/ai-elements/loader";
15
+ import { MetadataFilter } from "../components/MetadataFilter";
15
16
  import {
16
17
  ThoroughnessSelector,
17
18
  type Thoroughness,
@@ -37,6 +38,7 @@ import { apiFetch } from "../hooks/use-api";
37
38
  import { useDocEvents } from "../hooks/use-doc-events";
38
39
  import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
39
40
  import { buildDocDeepLink } from "../lib/deep-links";
41
+ import { parseMetadataFilter } from "../lib/metadata-filter";
40
42
  import {
41
43
  applyFiltersToUrl,
42
44
  parseFiltersFromSearch,
@@ -111,6 +113,7 @@ interface SearchResult {
111
113
  interface SearchResponse {
112
114
  results: SearchResult[];
113
115
  meta: {
116
+ warnings?: { code: string; message: string }[];
114
117
  query: string;
115
118
  mode: string;
116
119
  totalResults: number;
@@ -152,6 +155,7 @@ export default function Search({ navigate }: PageProps) {
152
155
  const [results, setResults] = useState<SearchResult[]>([]);
153
156
  const [meta, setMeta] = useState<SearchResponse["meta"] | null>(null);
154
157
  const [loading, setLoading] = useState(false);
158
+ const searchRequest = useRef(0);
155
159
  const [error, setError] = useState<string | null>(null);
156
160
  const [searched, setSearched] = useState(false);
157
161
  const [capabilities, setCapabilities] = useState<ServerCapabilities | null>(
@@ -170,6 +174,7 @@ export default function Search({ navigate }: PageProps) {
170
174
  initialFilters.until ||
171
175
  initialFilters.category ||
172
176
  initialFilters.author ||
177
+ initialFilters.filter ||
173
178
  initialFilters.queryModes.length > 0
174
179
  )
175
180
  );
@@ -187,6 +192,9 @@ export default function Search({ navigate }: PageProps) {
187
192
  const [since, setSince] = useState(initialFilters.since);
188
193
  const [until, setUntil] = useState(initialFilters.until);
189
194
  const [category, setCategory] = useState(initialFilters.category);
195
+ const [metadataFilter, setMetadataFilter] = useState(
196
+ initialFilters.filter ?? ""
197
+ );
190
198
  const [author, setAuthor] = useState(initialFilters.author);
191
199
  const [queryModes, setQueryModes] = useState<QueryModeEntry[]>(
192
200
  initialFilters.queryModes
@@ -217,6 +225,7 @@ export default function Search({ navigate }: PageProps) {
217
225
  useEffect(() => {
218
226
  const url = new URL(window.location.href);
219
227
  applyFiltersToUrl(url, {
228
+ filter: metadataFilter,
220
229
  collection: selectedCollection,
221
230
  intent,
222
231
  candidateLimit,
@@ -232,6 +241,7 @@ export default function Search({ navigate }: PageProps) {
232
241
  window.history.replaceState({}, "", url.toString());
233
242
  }, [
234
243
  activeTags,
244
+ metadataFilter,
235
245
  author,
236
246
  candidateLimit,
237
247
  category,
@@ -319,10 +329,23 @@ export default function Search({ navigate }: PageProps) {
319
329
  const handleSearch = useCallback(
320
330
  async (e?: React.FormEvent) => {
321
331
  e?.preventDefault();
332
+ const request = ++searchRequest.current;
322
333
  if (!query.trim()) {
323
334
  return;
324
335
  }
336
+ const parsedFilter = parseMetadataFilter(metadataFilter);
337
+ if (parsedFilter.error) {
338
+ setLoading(false);
339
+ setResults([]);
340
+ setMeta(null);
341
+ setError(parsedFilter.error);
342
+ setShowAdvanced(true);
343
+ return;
344
+ }
325
345
  if (!structuredQueryState.ok) {
346
+ setLoading(false);
347
+ setResults([]);
348
+ setMeta(null);
326
349
  setError(structuredQueryState.error.message);
327
350
  return;
328
351
  }
@@ -351,6 +374,7 @@ export default function Search({ navigate }: PageProps) {
351
374
  limit: 20,
352
375
  };
353
376
 
377
+ if (parsedFilter.filter) body.filter = parsedFilter.filter;
354
378
  if (selectedCollection) {
355
379
  body.collection = selectedCollection;
356
380
  }
@@ -400,6 +424,7 @@ export default function Search({ navigate }: PageProps) {
400
424
  }
401
425
  );
402
426
 
427
+ if (request !== searchRequest.current) return;
403
428
  setLoading(false);
404
429
  if (fetchError) {
405
430
  setError(fetchError);
@@ -412,6 +437,7 @@ export default function Search({ navigate }: PageProps) {
412
437
  },
413
438
  [
414
439
  activeTags,
440
+ metadataFilter,
415
441
  author,
416
442
  candidateLimit,
417
443
  activePreset,
@@ -438,6 +464,7 @@ export default function Search({ navigate }: PageProps) {
438
464
  }, [
439
465
  activeTags,
440
466
  activePreset,
467
+ metadataFilter,
441
468
  author,
442
469
  candidateLimit,
443
470
  category,
@@ -477,6 +504,7 @@ export default function Search({ navigate }: PageProps) {
477
504
  since ? `since:${since}` : null,
478
505
  until ? `until:${until}` : null,
479
506
  category.trim() ? `category:${category.trim()}` : null,
507
+ metadataFilter.trim() ? "custom metadata" : null,
480
508
  author.trim() ? `author:${author.trim()}` : null,
481
509
  queryModes.length > 0 ? `${queryModes.length} query mode(s)` : null,
482
510
  ].filter((pill): pill is string => Boolean(pill));
@@ -493,6 +521,7 @@ export default function Search({ navigate }: PageProps) {
493
521
  setUntil("");
494
522
  setCategory("");
495
523
  setAuthor("");
524
+ setMetadataFilter("");
496
525
  setQueryModes([]);
497
526
  setQueryModeText("");
498
527
  setQueryModeError(null);
@@ -773,6 +802,11 @@ export default function Search({ navigate }: PageProps) {
773
802
  </div>
774
803
  </div>
775
804
 
805
+ <MetadataFilter
806
+ onChange={setMetadataFilter}
807
+ value={metadataFilter}
808
+ />
809
+
776
810
  <div className="flex flex-wrap items-center gap-2">
777
811
  <span className="text-muted-foreground text-xs">
778
812
  Tag match:
@@ -954,6 +988,15 @@ export default function Search({ navigate }: PageProps) {
954
988
  </div>
955
989
  )}
956
990
 
991
+ {meta?.warnings?.map((warning) => (
992
+ <p
993
+ className="mb-3 rounded border border-secondary/30 bg-secondary/10 p-3 text-sm"
994
+ key={warning.code}
995
+ role="status"
996
+ >
997
+ {warning.message}
998
+ </p>
999
+ ))}
957
1000
  {error && (
958
1001
  <Card className="mb-6 border-destructive bg-destructive/10">
959
1002
  <CardContent className="py-4 text-destructive">
@@ -124,6 +124,11 @@ import {
124
124
  parseAndValidateTagFilter,
125
125
  validateTag,
126
126
  } from "../../core/tags";
127
+ import {
128
+ metadataPredicateSchema,
129
+ normalizeMetadataPredicate,
130
+ type MetadataPredicate,
131
+ } from "../../core/typed-metadata";
127
132
  import { validateRelPath } from "../../core/validation";
128
133
  import { writeLeasePath } from "../../core/write-lease";
129
134
  import {
@@ -287,6 +292,7 @@ export interface SearchRequestBody {
287
292
  /** Comma-separated category filters */
288
293
  category?: string;
289
294
  author?: string;
295
+ filter?: MetadataPredicate;
290
296
  /** Comma-separated tags - filter to docs having ALL (AND) */
291
297
  tagsAll?: string;
292
298
  /** Comma-separated tags - filter to docs having ANY (OR) */
@@ -308,6 +314,7 @@ export interface QueryRequestBody {
308
314
  /** Comma-separated category filters */
309
315
  category?: string;
310
316
  author?: string;
317
+ filter?: MetadataPredicate;
311
318
  queryModes?: QueryModeInput[];
312
319
  noExpand?: boolean;
313
320
  noRerank?: boolean;
@@ -339,6 +346,7 @@ export interface AskRequestBody {
339
346
  /** Comma-separated category filters */
340
347
  category?: string;
341
348
  author?: string;
349
+ filter?: MetadataPredicate;
342
350
  maxAnswerTokens?: number;
343
351
  verify?: boolean;
344
352
  contextBudgetTokens?: number;
@@ -369,6 +377,7 @@ const ASK_REQUEST_KEYS = new Set<keyof AskRequestBody>([
369
377
  "until",
370
378
  "category",
371
379
  "author",
380
+ "filter",
372
381
  "maxAnswerTokens",
373
382
  "verify",
374
383
  "contextBudgetTokens",
@@ -586,6 +595,29 @@ function errorResponse(
586
595
  );
587
596
  }
588
597
 
598
+ function parseRestMetadataFilter(
599
+ value: unknown
600
+ ):
601
+ | { ok: true; filter: MetadataPredicate | undefined }
602
+ | { ok: false; response: Response } {
603
+ if (value === undefined) return { ok: true, filter: undefined };
604
+ const parsed = metadataPredicateSchema.safeParse(value);
605
+ if (!parsed.success) {
606
+ const issue = parsed.error.issues[0];
607
+ const path = ["filter", ...(issue?.path ?? [])].join(".");
608
+ return {
609
+ ok: false,
610
+ response: errorResponse(
611
+ "VALIDATION",
612
+ `${path}: ${issue?.message ?? "Invalid predicate"}`,
613
+ 400,
614
+ { field: path }
615
+ ),
616
+ };
617
+ }
618
+ return { ok: true, filter: normalizeMetadataPredicate(parsed.data) };
619
+ }
620
+
589
621
  function fileRefactorHttpErrorResponse(error: FileRefactorHttpError): Response {
590
622
  return errorResponse(error.code, error.message, error.status, error.details);
591
623
  }
@@ -2090,6 +2122,8 @@ export async function handleDoc(
2090
2122
  title: doc.title,
2091
2123
  content,
2092
2124
  contentAvailable,
2125
+ typedMetadata: doc.typedMetadata ?? null,
2126
+ metadataError: doc.metadataError ?? null,
2093
2127
  collection: doc.collection,
2094
2128
  relPath,
2095
2129
  tags,
@@ -4194,6 +4228,9 @@ export async function handleSearch(
4194
4228
  ? parseCommaSeparatedValues(body.exclude)
4195
4229
  : undefined;
4196
4230
  const author = body.author?.trim() || undefined;
4231
+ const parsedFilter = parseRestMetadataFilter(body.filter);
4232
+ if (!parsedFilter.ok) return parsedFilter.response;
4233
+ const filter = parsedFilter.filter;
4197
4234
  let projectAffinity: ProjectAffinityScoringInput | undefined;
4198
4235
  try {
4199
4236
  projectAffinity = context
@@ -4221,6 +4258,7 @@ export async function handleSearch(
4221
4258
  until: body.until,
4222
4259
  categories,
4223
4260
  author,
4261
+ filter,
4224
4262
  projectAffinity,
4225
4263
  contentTypeRules: context
4226
4264
  ? normalizeContentTypes(context.config.contentTypes ?? []).rules
@@ -4388,6 +4426,9 @@ export async function handleQuery(
4388
4426
  ? parseCommaSeparatedValues(body.exclude)
4389
4427
  : undefined;
4390
4428
  const author = body.author?.trim() || undefined;
4429
+ const parsedFilter = parseRestMetadataFilter(body.filter);
4430
+ if (!parsedFilter.ok) return parsedFilter.response;
4431
+ const filter = parsedFilter.filter;
4391
4432
  let projectAffinity: ProjectAffinityScoringInput | undefined;
4392
4433
  try {
4393
4434
  projectAffinity = await resolveRemoteProjectAffinity(
@@ -4425,6 +4466,7 @@ export async function handleQuery(
4425
4466
  until: body.until,
4426
4467
  categories,
4427
4468
  author,
4469
+ filter,
4428
4470
  projectAffinity,
4429
4471
  explain: body.explain,
4430
4472
  };
@@ -4603,6 +4645,9 @@ export async function handleQueryDiagnose(
4603
4645
  ? parseCommaSeparatedValues(body.exclude)
4604
4646
  : undefined;
4605
4647
  const author = body.author?.trim() || undefined;
4648
+ const parsedFilter = parseRestMetadataFilter(body.filter);
4649
+ if (!parsedFilter.ok) return parsedFilter.response;
4650
+ const filter = parsedFilter.filter;
4606
4651
  const contentTypeRules = normalizeContentTypes(
4607
4652
  ctx.config.contentTypes ?? []
4608
4653
  ).rules;
@@ -4654,6 +4699,7 @@ export async function handleQueryDiagnose(
4654
4699
  until: body.until,
4655
4700
  categories,
4656
4701
  author,
4702
+ filter,
4657
4703
  projectAffinity,
4658
4704
  contentTypeRules,
4659
4705
  contentTypeRulesFingerprint:
@@ -4847,6 +4893,9 @@ export async function handleAsk(
4847
4893
  ? parseCommaSeparatedValues(body.exclude)
4848
4894
  : undefined;
4849
4895
  const author = body.author?.trim() || undefined;
4896
+ const parsedFilter = parseRestMetadataFilter(body.filter);
4897
+ if (!parsedFilter.ok) return parsedFilter.response;
4898
+ const filter = parsedFilter.filter;
4850
4899
  let projectAffinity: ProjectAffinityScoringInput | undefined;
4851
4900
  try {
4852
4901
  projectAffinity = await resolveRemoteProjectAffinity(
@@ -4885,6 +4934,7 @@ export async function handleAsk(
4885
4934
  until: body.until,
4886
4935
  categories,
4887
4936
  author,
4937
+ filter,
4888
4938
  verify: body.verify,
4889
4939
  contextBudgetTokens: body.contextBudgetTokens,
4890
4940
  contextBudgetBytes: body.contextBudgetBytes,
@@ -0,0 +1,13 @@
1
+ import type { Migration } from "./runner";
2
+
3
+ export const migration: Migration = {
4
+ version: 30,
5
+ name: "typed_metadata",
6
+ up(db): void {
7
+ db.exec("ALTER TABLE documents ADD COLUMN typed_metadata TEXT");
8
+ db.exec("ALTER TABLE documents ADD COLUMN metadata_error TEXT");
9
+ },
10
+ down(): void {
11
+ // Derived columns are retained; SQLite rollback must not rewrite source rows.
12
+ },
13
+ };
@@ -43,6 +43,7 @@ import { migration as m026 } from "./026-file-refactor-recovery-journal";
43
43
  import { migration as m027 } from "./027-memory-scopes";
44
44
  import { migration as m028 } from "./028-vector-variants";
45
45
  import { migration as m029 } from "./029-graph-reference-state";
46
+ import { migration as m030 } from "./030-typed-metadata";
46
47
 
47
48
  /** All migrations in order */
48
49
  export const migrations = [
@@ -75,4 +76,5 @@ export const migrations = [
75
76
  m027,
76
77
  m028,
77
78
  m029,
79
+ m030,
78
80
  ];