@gmickel/gno 0.37.0 → 0.38.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
@@ -85,45 +85,15 @@ gno daemon
85
85
 
86
86
  ---
87
87
 
88
- ## What's New in v0.29
88
+ ## What's New
89
89
 
90
- - **GNO Desktop Beta**: first mac-first desktop beta shell with deep-link routing, singleton handoff, and the same onboarding/search/edit flows as `gno serve`
91
- - **Desktop Onboarding Polish**: guided setup now covers folders, presets, model readiness, indexing, connectors, import preview, app tabs, file actions, and recovery without drift between web and desktop
92
- - **Default Preset Upgrade**: `slim-tuned` is now the built-in default, using the fine-tuned retrieval expansion model while keeping the same embed, rerank, and answer stack as `slim`
93
- - **Workspace UI Polish**: richer scholarly-dusk presentation across dashboard, tabs, search, ask, footer, and global styling without introducing external font or asset dependencies
90
+ > Latest release: [v0.37.0](./CHANGELOG.md#0370---2026-04-06)
91
+ > Full release history: [CHANGELOG.md](./CHANGELOG.md)
94
92
 
95
- ## What's New in v0.30
96
-
97
- - **Headless Daemon Mode**: `gno daemon` keeps your index fresh continuously without opening the Web UI
98
- - **CLI Concurrency Hardening**: read-only commands no longer trip transient `database is locked` errors when they overlap with `gno update`
99
- - **Web/Desktop UI Polish**: sharper workspace styling across dashboard, tabs, search, ask, and footer surfaces
100
-
101
- ## What's New in v0.31
102
-
103
- - **Windows Desktop Beta Artifact**: release flow now includes a packaged `windows-x64` desktop beta zip, not just source-level support claims
104
- - **Packaged Runtime Proof**: Windows desktop packaging validates bundled Bun + staged GNO runtime + FTS5 + vendored snowball + `sqlite-vec`
105
- - **Scoped Index Fix**: `gno index <collection>` now embeds only that collection instead of accidentally burning through unrelated backlog from other collections
106
- - **CLI Reporting Fix**: long embed runs now report sane durations instead of bogus sub-second summaries
107
-
108
- ### v0.24
109
-
110
- - **Structured Query Documents**: first-class multi-line query syntax using `term:`, `intent:`, and `hyde:`
111
- - **Cross-Surface Rollout**: works across CLI, API, MCP, SDK, and Web Search/Ask
112
- - **Portable Retrieval Prompts**: save/share advanced retrieval intent as one text payload instead of repeated flags or JSON arrays
113
-
114
- ### v0.23
115
-
116
- - **SDK / Library Mode**: package-root importable SDK with `createGnoClient(...)` for direct retrieval, document access, and indexing flows
117
- - **Inline Config Support**: embed GNO in another app without writing YAML config files
118
- - **Programmatic Indexing**: call `update`, `embed`, and `index` directly from Bun/TypeScript
119
- - **Docs & Website**: dedicated SDK guide, feature page, homepage section, and architecture docs
120
-
121
- ### v0.22
122
-
123
- - **Promoted Slim Retrieval Model**: published `slim-retrieval-v1` on Hugging Face for direct `hf:` installation in GNO
124
- - **Fine-Tuning Workflow**: local MLX LoRA training, portable GGUF export, automatic checkpoint selection, promotion bundles, and repeatable benchmark comparisons
125
- - **Autonomous Search Harness**: bounded candidate search with early-stop guards, repeated incumbent confirmation, and promotion targets
126
- - **Public Docs & Site**: fine-tuned model docs and feature pages now point at the published HF model and the `slim-tuned` preset
93
+ - **Retrieval Quality Upgrade**: stronger BM25 lexical handling, code-aware chunking, terminal result hyperlinks, and per-collection model overrides
94
+ - **Code Embedding Benchmarks**: new benchmark workflow across canonical, real-GNO, and pinned OSS slices for comparing alternate embedding models
95
+ - **Recommended Code Embed Model**: docs and benchmark pages now point to `Qwen3-Embedding-0.6B-GGUF` as the current code-specialist embedding option
96
+ - **Regression Fixes**: tightened phrase/negation/hyphen/underscore BM25 behavior, cleaned non-TTY hyperlink output, improved `gno doctor` chunking visibility, and fixed the embedding autoresearch harness
127
97
 
128
98
  ### Fine-Tuned Model Quick Use
129
99
 
@@ -150,58 +120,6 @@ gno query "ECONNREFUSED 127.0.0.1:5432" --thorough
150
120
 
151
121
  > Full guide: [Fine-Tuned Models](https://gno.sh/docs/FINE-TUNED-MODELS/) · [Feature page](https://gno.sh/features/fine-tuned-models/)
152
122
 
153
- ## What's New in v0.21
154
-
155
- - **Ask CLI Query Modes**: `gno ask` now accepts repeatable `--query-mode term|intent|hyde` entries, matching the existing Ask API and Web controls
156
-
157
- ### v0.20
158
-
159
- - **Improved Model Init Fallbacks**: upgraded `node-llama-cpp` to `3.17.1` and switched to `build: "autoAttempt"` for better backend selection/fallback behavior
160
-
161
- ### v0.19
162
-
163
- - **Exclusion Filters**: explicit `exclude` controls across CLI, API, Web, and MCP to hard-prune unwanted docs by title/path/body text
164
- - **Ask Query-Mode Parity**: Ask now supports structured `term` / `intent` / `hyde` controls in both API and Web UI
165
-
166
- ### v0.18
167
-
168
- - **Intent Steering**: optional `intent` control for ambiguous queries across CLI, API, Web, and MCP query flows
169
- - **Rerank Controls**: `candidateLimit` lets you tune rerank cost vs. recall on slower or memory-constrained machines
170
- - **Stability**: query expansion now uses a bounded configurable context size (`models.expandContextSize`, default `2048`)
171
- - **Rerank Efficiency**: identical chunk texts are deduplicated before scoring and expanded back out deterministically
172
-
173
- ### v0.17
174
-
175
- - **Structured Query Modes**: `term`, `intent`, and `hyde` controls across CLI, API, MCP, and Web
176
- - **Temporal Retrieval Upgrades**: `since`/`until`, date-range parsing, and recency sorting with frontmatter-date fallback
177
- - **Web Retrieval UX Polish**: richer advanced controls in Search and Ask (collection/date/category/author/tags + query modes)
178
- - **Metadata-Aware Retrieval**: ingestion now materializes document metadata/date fields for better filtering and ranking
179
- - **Migration Reliability**: SQLite-compatible migration path for existing indexes (including older SQLite engines)
180
-
181
- ### v0.15
182
-
183
- - **HTTP Backends**: Offload embedding, reranking, and generation to remote GPU servers
184
- - Simple URI config: `http://host:port/path#modelname`
185
- - Works with llama-server, Ollama, LocalAI, vLLM
186
- - Run GNO on lightweight machines while GPU inference runs on your network
187
-
188
- ### v0.13
189
-
190
- - **Knowledge Graph**: Interactive force-directed visualization of document connections
191
- - **Graph with Similarity**: See semantic similarity as golden edges (not just wiki/markdown links)
192
- - **CLI**: `gno graph` command with collection filtering and similarity options
193
- - **Web UI**: `/graph` page with zoom, pan, collection filter, similarity toggle
194
- - **MCP**: `gno_graph` tool for AI agents to explore document relationships
195
- - **REST API**: `/api/graph` endpoint with full query parameters
196
-
197
- ### v0.12
198
-
199
- - **Note Linking**: Wiki-style `[[links]]`, backlinks, and AI-powered related notes
200
- - **Tag System**: Filter searches by frontmatter tags with `--tags-any`/`--tags-all`
201
- - **Web UI**: Outgoing links panel, backlinks panel, related notes sidebar
202
- - **CLI**: `gno links`, `gno backlinks`, `gno similar` commands
203
- - **MCP**: `gno_links`, `gno_backlinks`, `gno_similar` tools
204
-
205
123
  ---
206
124
 
207
125
  ## Quick Start
@@ -177,6 +177,28 @@ gno embed # Embed only (if already synced)
177
177
 
178
178
  MCP `gno.sync` and `gno.capture` do NOT auto-embed. Use CLI for embedding.
179
179
 
180
+ ## Collection-specific embedding models
181
+
182
+ Collections can override the global embedding model with `models.embed`.
183
+
184
+ CLI path:
185
+
186
+ ```bash
187
+ gno collection add ~/work/gno/src \
188
+ --name gno-code \
189
+ --embed-model "hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf"
190
+ ```
191
+
192
+ Good default guidance:
193
+
194
+ - keep the global preset for mixed notes/docs collections
195
+ - use a collection-specific embed override for code-heavy collections when benchmark guidance says so
196
+ - after changing an embed model on an existing populated collection, run:
197
+
198
+ ```bash
199
+ gno embed --collection gno-code
200
+ ```
201
+
180
202
  ## Reference Documentation
181
203
 
182
204
  | Topic | File |
@@ -42,7 +42,11 @@ gno init [<path>] [options]
42
42
  gno collection add <path> --name <name> [options]
43
43
  ```
44
44
 
45
- Options same as `init`.
45
+ Options same as `init`, plus:
46
+
47
+ | Option | Description |
48
+ | --------------------- | ---------------------------------------------------- |
49
+ | `--embed-model <uri>` | Initial collection-specific embedding model override |
46
50
 
47
51
  ### gno collection list
48
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -12,6 +12,7 @@ import {
12
12
  import { CliError } from "../../errors";
13
13
 
14
14
  interface AddOptions {
15
+ embedModel?: string;
15
16
  name?: string;
16
17
  pattern?: string;
17
18
  include?: string;
@@ -51,6 +52,11 @@ export async function collectionAdd(
51
52
  pattern: options.pattern,
52
53
  include: options.include,
53
54
  exclude: options.exclude,
55
+ models: options.embedModel
56
+ ? {
57
+ embed: options.embedModel,
58
+ }
59
+ : undefined,
54
60
  updateCmd: options.update,
55
61
  });
56
62
 
@@ -1264,10 +1264,12 @@ function wireManagementCommands(program: Command): void {
1264
1264
  .option("--pattern <glob>", "file matching pattern")
1265
1265
  .option("--include <exts>", "extension allowlist (CSV)")
1266
1266
  .option("--exclude <patterns>", "exclude patterns (CSV)")
1267
+ .option("--embed-model <uri>", "collection-specific embedding model URI")
1267
1268
  .option("--update <cmd>", "shell command to run before indexing")
1268
1269
  .action(async (path: string, cmdOpts: Record<string, unknown>) => {
1269
1270
  const { collectionAdd } = await import("./commands/collection");
1270
1271
  await collectionAdd(path, {
1272
+ embedModel: cmdOpts.embedModel as string | undefined,
1271
1273
  name: cmdOpts.name as string,
1272
1274
  pattern: cmdOpts.pattern as string | undefined,
1273
1275
  include: cmdOpts.include as string | undefined,
@@ -97,6 +97,7 @@ export async function addCollection(
97
97
  pattern: input.pattern ?? DEFAULT_PATTERN,
98
98
  include: includeList,
99
99
  exclude: excludeList,
100
+ models: input.models,
100
101
  updateCmd: input.updateCmd,
101
102
  };
102
103
 
@@ -7,6 +7,7 @@
7
7
 
8
8
  export { addCollection } from "./add";
9
9
  export { removeCollection } from "./remove";
10
+ export { updateCollection } from "./update";
10
11
  export type {
11
12
  AddCollectionInput,
12
13
  CollectionError,
@@ -14,4 +15,5 @@ export type {
14
15
  CollectionSuccess,
15
16
  RemoveCollectionInput,
16
17
  RenameCollectionInput,
18
+ UpdateCollectionInput,
17
19
  } from "./types";
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Collection, Config } from "../config/types";
8
+ import type { ModelType } from "../llm/types";
8
9
 
9
10
  /**
10
11
  * Input for adding a collection.
@@ -22,6 +23,8 @@ export interface AddCollectionInput {
22
23
  exclude?: string[] | string;
23
24
  /** Update command to run before sync */
24
25
  updateCmd?: string;
26
+ /** Optional initial model overrides */
27
+ models?: Partial<Record<ModelType, string>>;
25
28
  }
26
29
 
27
30
  /**
@@ -42,6 +45,16 @@ export interface RenameCollectionInput {
42
45
  newName: string;
43
46
  }
44
47
 
48
+ /**
49
+ * Input for updating a collection.
50
+ */
51
+ export interface UpdateCollectionInput {
52
+ /** Collection name (case-insensitive) */
53
+ name: string;
54
+ /** Partial model override patch; null clears one role */
55
+ models?: Partial<Record<ModelType, string | null>>;
56
+ }
57
+
45
58
  /**
46
59
  * Successful collection operation result.
47
60
  */
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Update collection core logic.
3
+ * Pure function that mutates config - caller handles I/O.
4
+ *
5
+ * @module src/collection/update
6
+ */
7
+
8
+ import type {
9
+ Collection,
10
+ CollectionModelOverrides,
11
+ Config,
12
+ } from "../config/types";
13
+ import type { CollectionResult, UpdateCollectionInput } from "./types";
14
+
15
+ import { CollectionSchema } from "../config";
16
+
17
+ function normalizeOverrides(
18
+ models?: UpdateCollectionInput["models"]
19
+ ): CollectionModelOverrides | undefined {
20
+ if (!models) {
21
+ return undefined;
22
+ }
23
+
24
+ const entries = Object.entries(models).filter(
25
+ ([, value]) => value !== undefined && value !== null
26
+ );
27
+ if (entries.length === 0) {
28
+ return undefined;
29
+ }
30
+
31
+ return Object.fromEntries(entries) as CollectionModelOverrides;
32
+ }
33
+
34
+ /**
35
+ * Update a collection in config.
36
+ */
37
+ export function updateCollection(
38
+ config: Config,
39
+ input: UpdateCollectionInput
40
+ ): CollectionResult {
41
+ const collectionName = input.name.toLowerCase();
42
+ const index = config.collections.findIndex((c) => c.name === collectionName);
43
+ if (index < 0) {
44
+ return {
45
+ ok: false,
46
+ code: "NOT_FOUND",
47
+ message: `Collection "${collectionName}" not found`,
48
+ };
49
+ }
50
+
51
+ const current = config.collections[index];
52
+ if (!current) {
53
+ return {
54
+ ok: false,
55
+ code: "NOT_FOUND",
56
+ message: `Collection "${collectionName}" not found`,
57
+ };
58
+ }
59
+
60
+ let nextModels = current.models;
61
+ if (input.models) {
62
+ nextModels = normalizeOverrides({
63
+ ...current.models,
64
+ ...input.models,
65
+ });
66
+ }
67
+
68
+ const nextCollection: Collection = {
69
+ ...current,
70
+ models: nextModels,
71
+ };
72
+
73
+ const validation = CollectionSchema.safeParse(nextCollection);
74
+ if (!validation.success) {
75
+ return {
76
+ ok: false,
77
+ code: "VALIDATION",
78
+ message: `Invalid collection: ${validation.error.issues[0]?.message ?? "unknown error"}`,
79
+ };
80
+ }
81
+
82
+ const nextCollections = [...config.collections];
83
+ nextCollections[index] = validation.data;
84
+
85
+ return {
86
+ ok: true,
87
+ config: {
88
+ ...config,
89
+ collections: nextCollections,
90
+ },
91
+ collection: validation.data,
92
+ };
93
+ }
@@ -15,6 +15,10 @@ import type { ModelType } from "./types";
15
15
 
16
16
  import { DEFAULT_MODEL_PRESETS } from "../config/types";
17
17
 
18
+ export type ModelResolutionSource = "override" | "preset" | "default";
19
+ export type ModelResolutionMap = Record<ModelType, string>;
20
+ export type ModelResolutionSourceMap = Record<ModelType, ModelResolutionSource>;
21
+
18
22
  // ─────────────────────────────────────────────────────────────────────────────
19
23
  // Registry Functions
20
24
  // ─────────────────────────────────────────────────────────────────────────────
@@ -130,6 +134,55 @@ export function resolveModelUri(
130
134
  return preset[type];
131
135
  }
132
136
 
137
+ export function resolveModelSource(
138
+ config: Config,
139
+ type: ModelType,
140
+ override?: string,
141
+ collection?: string
142
+ ): ModelResolutionSource {
143
+ if (override) {
144
+ return "override";
145
+ }
146
+ const collectionModels = getCollectionModelOverrides(config, collection);
147
+ if (collectionModels?.[type]) {
148
+ return "override";
149
+ }
150
+
151
+ const modelConfig = getModelConfig(config);
152
+ const preset = modelConfig.presets.find(
153
+ (p) => p.id === modelConfig.activePreset
154
+ );
155
+ if (preset) {
156
+ return "preset";
157
+ }
158
+
159
+ return "default";
160
+ }
161
+
162
+ export function getCollectionEffectiveModels(
163
+ config: Config,
164
+ collection?: string
165
+ ): ModelResolutionMap {
166
+ return {
167
+ embed: resolveModelUri(config, "embed", undefined, collection),
168
+ rerank: resolveModelUri(config, "rerank", undefined, collection),
169
+ expand: resolveModelUri(config, "expand", undefined, collection),
170
+ gen: resolveModelUri(config, "gen", undefined, collection),
171
+ };
172
+ }
173
+
174
+ export function getCollectionModelSources(
175
+ config: Config,
176
+ collection?: string
177
+ ): ModelResolutionSourceMap {
178
+ return {
179
+ embed: resolveModelSource(config, "embed", undefined, collection),
180
+ rerank: resolveModelSource(config, "rerank", undefined, collection),
181
+ expand: resolveModelSource(config, "expand", undefined, collection),
182
+ gen: resolveModelSource(config, "gen", undefined, collection),
183
+ };
184
+ }
185
+
133
186
  /**
134
187
  * List all available presets.
135
188
  */
@@ -0,0 +1,397 @@
1
+ import {
2
+ AlertTriangleIcon,
3
+ CpuIcon,
4
+ Loader2Icon,
5
+ RotateCcwIcon,
6
+ SparklesIcon,
7
+ } from "lucide-react";
8
+ import { useEffect, useMemo, useState } from "react";
9
+
10
+ import { apiFetch } from "../hooks/use-api";
11
+ import { Button } from "./ui/button";
12
+ import {
13
+ Dialog,
14
+ DialogContent,
15
+ DialogDescription,
16
+ DialogFooter,
17
+ DialogHeader,
18
+ DialogTitle,
19
+ } from "./ui/dialog";
20
+ import { Input } from "./ui/input";
21
+
22
+ const MODEL_ROLES = ["embed", "rerank", "expand", "gen"] as const;
23
+
24
+ type ModelRole = (typeof MODEL_ROLES)[number];
25
+ type ModelSource = "override" | "preset" | "default";
26
+
27
+ const ROLE_LABELS: Record<ModelRole, string> = {
28
+ embed: "Embedding",
29
+ rerank: "Reranker",
30
+ expand: "Query Expansion",
31
+ gen: "Answer Model",
32
+ };
33
+
34
+ const ROLE_NOTES: Record<ModelRole, string> = {
35
+ embed: "Drives vector search and embedding backlog for this collection.",
36
+ rerank: "Scores candidate passages/documents after retrieval.",
37
+ expand:
38
+ "Generates lexical and semantic expansion variants for harder queries.",
39
+ gen: "Used for collection-targeted answer generation flows.",
40
+ };
41
+
42
+ export interface CollectionModelDetails {
43
+ activePresetId?: string;
44
+ chunkCount: number;
45
+ documentCount: number;
46
+ effectiveModels?: Record<ModelRole, string>;
47
+ include?: string[];
48
+ modelSources?: Record<ModelRole, ModelSource>;
49
+ models?: Partial<Record<ModelRole, string>>;
50
+ name: string;
51
+ path: string;
52
+ pattern?: string;
53
+ }
54
+
55
+ interface UpdateCollectionResponse {
56
+ collection: CollectionModelDetails;
57
+ success: boolean;
58
+ }
59
+
60
+ interface CollectionModelDialogProps {
61
+ collection: CollectionModelDetails | null;
62
+ onOpenChange: (open: boolean) => void;
63
+ onSaved: () => void;
64
+ open: boolean;
65
+ }
66
+
67
+ function normalizeValue(value: string | undefined): string {
68
+ return value?.trim() ?? "";
69
+ }
70
+
71
+ const CODE_EMBED_RECOMMENDATION =
72
+ "hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf";
73
+
74
+ const CODE_PATH_HINTS = [
75
+ "/src",
76
+ "/lib",
77
+ "/app",
78
+ "/apps",
79
+ "/packages",
80
+ "/server",
81
+ "/services",
82
+ ] as const;
83
+
84
+ const CODE_EXT_HINTS = [
85
+ ".ts",
86
+ ".tsx",
87
+ ".js",
88
+ ".jsx",
89
+ ".go",
90
+ ".rs",
91
+ ".py",
92
+ ".swift",
93
+ ".c",
94
+ ] as const;
95
+
96
+ function collectionLooksCodeHeavy(collection: CollectionModelDetails): boolean {
97
+ const path = collection.path.toLowerCase();
98
+ if (
99
+ CODE_PATH_HINTS.some(
100
+ (hint) => path.endsWith(hint) || path.includes(`${hint}/`)
101
+ )
102
+ ) {
103
+ return true;
104
+ }
105
+
106
+ const includeValues = collection.include ?? [];
107
+ if (
108
+ includeValues.some((value) =>
109
+ CODE_EXT_HINTS.some((ext) => value.includes(ext))
110
+ )
111
+ ) {
112
+ return true;
113
+ }
114
+
115
+ const pattern = collection.pattern?.toLowerCase() ?? "";
116
+ return CODE_EXT_HINTS.some(
117
+ (ext) => pattern.includes(ext) || pattern.includes(ext.replace(".", ""))
118
+ );
119
+ }
120
+
121
+ export function CollectionModelDialog({
122
+ collection,
123
+ onOpenChange,
124
+ onSaved,
125
+ open,
126
+ }: CollectionModelDialogProps) {
127
+ const [draft, setDraft] = useState<Record<ModelRole, string>>({
128
+ embed: "",
129
+ rerank: "",
130
+ expand: "",
131
+ gen: "",
132
+ });
133
+ const [error, setError] = useState<string | null>(null);
134
+ const [saving, setSaving] = useState(false);
135
+ const showCodeRecommendation =
136
+ collection !== null && collectionLooksCodeHeavy(collection);
137
+
138
+ useEffect(() => {
139
+ if (!open || !collection) {
140
+ return;
141
+ }
142
+
143
+ setDraft({
144
+ embed: collection.models?.embed ?? "",
145
+ rerank: collection.models?.rerank ?? "",
146
+ expand: collection.models?.expand ?? "",
147
+ gen: collection.models?.gen ?? "",
148
+ });
149
+ setError(null);
150
+ setSaving(false);
151
+ }, [collection, open]);
152
+
153
+ const patch = useMemo(() => {
154
+ if (!collection) {
155
+ return {};
156
+ }
157
+
158
+ const nextPatch: Partial<Record<ModelRole, string | null>> = {};
159
+ for (const role of MODEL_ROLES) {
160
+ const original = normalizeValue(collection.models?.[role]);
161
+ const current = normalizeValue(draft[role]);
162
+ if (original === current) {
163
+ continue;
164
+ }
165
+ nextPatch[role] = current.length === 0 ? null : current;
166
+ }
167
+
168
+ return nextPatch;
169
+ }, [collection, draft]);
170
+
171
+ const hasChanges = Object.keys(patch).length > 0;
172
+ const embedChanged = Object.hasOwn(patch, "embed");
173
+
174
+ const handleSave = async () => {
175
+ if (!collection || !hasChanges) {
176
+ return;
177
+ }
178
+
179
+ setSaving(true);
180
+ setError(null);
181
+
182
+ const { error: requestError } = await apiFetch<UpdateCollectionResponse>(
183
+ `/api/collections/${encodeURIComponent(collection.name)}`,
184
+ {
185
+ method: "PATCH",
186
+ body: JSON.stringify({ models: patch }),
187
+ }
188
+ );
189
+
190
+ setSaving(false);
191
+
192
+ if (requestError) {
193
+ setError(requestError);
194
+ return;
195
+ }
196
+
197
+ onSaved();
198
+ onOpenChange(false);
199
+ };
200
+
201
+ return (
202
+ <Dialog onOpenChange={onOpenChange} open={open}>
203
+ <DialogContent className="flex max-h-[85vh] max-w-3xl flex-col gap-0 overflow-x-hidden border-none bg-[#0f1115] p-0 shadow-[0_30px_90px_-35px_rgba(0,0,0,0.8)]">
204
+ {/* Header */}
205
+ <DialogHeader className="shrink-0 border-border/20 border-b px-6 pt-5 pb-4 text-left">
206
+ <div className="flex items-start justify-between gap-4">
207
+ <div className="min-w-0 space-y-1.5">
208
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
209
+ <span className="rounded bg-muted/40 px-2 py-0.5 font-mono text-[10px] text-muted-foreground/70 uppercase tracking-[0.12em]">
210
+ Collection models
211
+ </span>
212
+ <span className="rounded bg-muted/30 px-2 py-0.5 font-mono text-[10px] text-muted-foreground/50 tracking-[0.05em]">
213
+ preset: {collection?.activePresetId ?? "unknown"}
214
+ </span>
215
+ </div>
216
+ <DialogTitle className="font-[Iowan_Old_Style,Palatino_Linotype,Palatino,Book_Antiqua,Georgia,serif] text-2xl leading-tight">
217
+ {collection?.name ?? "Collection"}
218
+ </DialogTitle>
219
+ <DialogDescription className="text-muted-foreground/70 text-[13px]">
220
+ Override model roles for one collection without changing the
221
+ active preset for the rest of the workspace.
222
+ </DialogDescription>
223
+ </div>
224
+ <div className="hidden shrink-0 max-w-[200px] space-y-1 border-border/15 border-l pl-4 lg:block">
225
+ <p className="font-mono text-[10px] text-muted-foreground/50 uppercase tracking-[0.15em]">
226
+ Path
227
+ </p>
228
+ <p
229
+ className="break-all font-mono text-[10px] leading-relaxed text-muted-foreground/50"
230
+ title={collection?.path}
231
+ >
232
+ {collection?.path}
233
+ </p>
234
+ </div>
235
+ </div>
236
+ </DialogHeader>
237
+
238
+ {/* Scrollable model roles */}
239
+ <div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
240
+ <div className="divide-y divide-border/15">
241
+ {MODEL_ROLES.map((role) => {
242
+ const source = collection?.modelSources?.[role] ?? "preset";
243
+ const effectiveValue = collection?.effectiveModels?.[role] ?? "";
244
+ const draftValue = draft[role];
245
+ const isOverride = source === "override";
246
+
247
+ return (
248
+ <div
249
+ className="grid gap-x-5 gap-y-3 px-6 py-4 lg:grid-cols-[180px_minmax(0,1fr)]"
250
+ key={role}
251
+ >
252
+ {/* Left: role info */}
253
+ <div className="space-y-1">
254
+ <div className="flex items-center gap-2">
255
+ <CpuIcon className="size-3.5 text-secondary/70" />
256
+ <h3 className="font-medium text-[13px]">
257
+ {ROLE_LABELS[role]}
258
+ </h3>
259
+ </div>
260
+ <p className="text-muted-foreground/50 text-xs leading-relaxed">
261
+ {ROLE_NOTES[role]}
262
+ </p>
263
+ </div>
264
+
265
+ {/* Right: controls */}
266
+ <div className="space-y-2.5">
267
+ {/* Source + effective model */}
268
+ <div className="flex items-baseline gap-2">
269
+ <span
270
+ className={`shrink-0 rounded px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.1em] ${
271
+ isOverride
272
+ ? "bg-secondary/15 text-secondary/80"
273
+ : "bg-muted/40 text-muted-foreground/50"
274
+ }`}
275
+ >
276
+ {isOverride ? "override" : "inherits"}
277
+ </span>
278
+ <span className="font-mono text-[9px] text-muted-foreground/35 uppercase tracking-[0.12em]">
279
+ effective
280
+ </span>
281
+ </div>
282
+ <p
283
+ className="break-all font-mono text-[11px] leading-relaxed text-foreground/70"
284
+ title={effectiveValue}
285
+ >
286
+ {effectiveValue}
287
+ </p>
288
+
289
+ {/* Code embed recommendation */}
290
+ {role === "embed" && showCodeRecommendation ? (
291
+ <button
292
+ className="flex w-full cursor-pointer items-center gap-2.5 rounded-md border border-secondary/15 bg-secondary/5 px-3 py-2 text-left transition-colors hover:border-secondary/25 hover:bg-secondary/8"
293
+ onClick={() =>
294
+ setDraft((current) => ({
295
+ ...current,
296
+ embed: CODE_EMBED_RECOMMENDATION,
297
+ }))
298
+ }
299
+ type="button"
300
+ >
301
+ <SparklesIcon className="size-3.5 shrink-0 text-secondary/60" />
302
+ <div className="min-w-0">
303
+ <p className="text-[11px] text-foreground/70">
304
+ Apply code-optimized embedding
305
+ </p>
306
+ <p className="truncate font-mono text-[10px] text-muted-foreground/40">
307
+ {CODE_EMBED_RECOMMENDATION}
308
+ </p>
309
+ </div>
310
+ </button>
311
+ ) : null}
312
+
313
+ {/* Input + reset */}
314
+ <div className="flex items-center gap-1.5">
315
+ <Input
316
+ className="h-8 border-border/20 bg-muted/10 font-mono text-[11px] placeholder:text-muted-foreground/25"
317
+ onChange={(event) =>
318
+ setDraft((current) => ({
319
+ ...current,
320
+ [role]: event.target.value,
321
+ }))
322
+ }
323
+ placeholder="Leave empty to inherit from preset"
324
+ value={draftValue}
325
+ />
326
+ <Button
327
+ className="size-8 shrink-0 border-border/20 text-muted-foreground/30 hover:text-muted-foreground/60"
328
+ disabled={draftValue.trim().length === 0}
329
+ onClick={() =>
330
+ setDraft((current) => ({
331
+ ...current,
332
+ [role]: "",
333
+ }))
334
+ }
335
+ size="icon-sm"
336
+ variant="outline"
337
+ >
338
+ <RotateCcwIcon className="size-3" />
339
+ </Button>
340
+ </div>
341
+ </div>
342
+ </div>
343
+ );
344
+ })}
345
+ </div>
346
+
347
+ {/* Warnings */}
348
+ {collection && embedChanged && collection.documentCount > 0 ? (
349
+ <div className="border-border/15 border-t px-6 py-3">
350
+ <div className="flex items-start gap-2.5 rounded-md border border-secondary/15 bg-secondary/5 px-3 py-2.5">
351
+ <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0 text-secondary/60" />
352
+ <div>
353
+ <p className="font-medium text-secondary/80 text-xs">
354
+ Re-index needed after save
355
+ </p>
356
+ <p className="mt-0.5 text-muted-foreground/50 text-xs">
357
+ {collection.documentCount} docs / {collection.chunkCount}{" "}
358
+ chunks will need re-embedding for the new model.
359
+ </p>
360
+ </div>
361
+ </div>
362
+ </div>
363
+ ) : null}
364
+
365
+ {error ? (
366
+ <div className="border-border/15 border-t px-6 py-3">
367
+ <div className="rounded-md border border-destructive/25 bg-destructive/8 px-3 py-2.5 text-destructive text-xs">
368
+ {error}
369
+ </div>
370
+ </div>
371
+ ) : null}
372
+ </div>
373
+
374
+ {/* Footer — always visible */}
375
+ <DialogFooter className="shrink-0 border-border/20 border-t px-6 py-3">
376
+ <Button
377
+ className="border-border/20 text-xs"
378
+ onClick={() => onOpenChange(false)}
379
+ size="sm"
380
+ variant="outline"
381
+ >
382
+ Cancel
383
+ </Button>
384
+ <Button
385
+ className="text-xs"
386
+ disabled={!hasChanges || saving}
387
+ onClick={() => void handleSave()}
388
+ size="sm"
389
+ >
390
+ {saving ? <Loader2Icon className="size-3.5 animate-spin" /> : null}
391
+ Save model settings
392
+ </Button>
393
+ </DialogFooter>
394
+ </DialogContent>
395
+ </Dialog>
396
+ );
397
+ }
@@ -12,6 +12,7 @@
12
12
  import {
13
13
  AlertCircleIcon,
14
14
  ArrowLeftIcon,
15
+ CpuIcon,
15
16
  DatabaseIcon,
16
17
  FileTextIcon,
17
18
  FolderIcon,
@@ -29,6 +30,10 @@ import type { AppStatusResponse } from "../../status-model";
29
30
 
30
31
  import { AddCollectionDialog } from "../components/AddCollectionDialog";
31
32
  import { Loader } from "../components/ai-elements/loader";
33
+ import {
34
+ CollectionModelDialog,
35
+ type CollectionModelDetails,
36
+ } from "../components/CollectionModelDialog";
32
37
  import { CollectionsEmptyState } from "../components/CollectionsEmptyState";
33
38
  import { IndexingProgress } from "../components/IndexingProgress";
34
39
  import { Badge } from "../components/ui/badge";
@@ -67,11 +72,20 @@ interface PageProps {
67
72
  }
68
73
 
69
74
  interface CollectionStats {
75
+ activePresetId?: string;
70
76
  name: string;
71
77
  path: string;
72
78
  documentCount: number;
73
79
  chunkCount: number;
74
80
  embeddedCount: number;
81
+ include?: string[];
82
+ models?: Partial<Record<"embed" | "rerank" | "expand" | "gen", string>>;
83
+ effectiveModels?: Record<"embed" | "rerank" | "expand" | "gen", string>;
84
+ modelSources?: Record<
85
+ "embed" | "rerank" | "expand" | "gen",
86
+ "override" | "preset" | "default"
87
+ >;
88
+ pattern?: string;
75
89
  }
76
90
 
77
91
  interface StatusResponse {
@@ -86,10 +100,13 @@ interface SyncResponse {
86
100
  jobId: string;
87
101
  }
88
102
 
103
+ interface CollectionsResponseItem extends CollectionModelDetails {}
104
+
89
105
  interface CollectionCardProps {
90
106
  actionsDisabled: boolean;
91
107
  collection: CollectionStats;
92
108
  onBrowse: () => void;
109
+ onModelSettings: () => void;
93
110
  onReindex: () => void;
94
111
  onRemove: () => void;
95
112
  isReindexing: boolean;
@@ -118,6 +135,7 @@ function CollectionCard({
118
135
  actionsDisabled,
119
136
  collection,
120
137
  onBrowse,
138
+ onModelSettings,
121
139
  onReindex,
122
140
  onRemove,
123
141
  isReindexing,
@@ -164,6 +182,17 @@ function CollectionCard({
164
182
  </Button>
165
183
  </DropdownMenuTrigger>
166
184
  <DropdownMenuContent align="end">
185
+ <DropdownMenuItem
186
+ disabled={actionsDisabled}
187
+ onClick={(event) => {
188
+ event.stopPropagation();
189
+ onModelSettings();
190
+ }}
191
+ >
192
+ <CpuIcon className="mr-2 size-4" />
193
+ Model settings
194
+ </DropdownMenuItem>
195
+ <DropdownMenuSeparator />
167
196
  <DropdownMenuItem
168
197
  disabled={actionsDisabled || isReindexing}
169
198
  onClick={(event) => {
@@ -244,6 +273,27 @@ function CollectionCard({
244
273
  </div>
245
274
  )}
246
275
 
276
+ {collection.modelSources ? (
277
+ <div className="mt-3 flex flex-wrap items-center gap-2 border-border/30 border-t pt-3">
278
+ {(["embed", "rerank", "expand", "gen"] as const).map((role) => {
279
+ const source = collection.modelSources?.[role];
280
+ if (source !== "override") {
281
+ return null;
282
+ }
283
+
284
+ return (
285
+ <Badge
286
+ className="font-mono text-[10px] uppercase tracking-[0.12em]"
287
+ key={role}
288
+ variant="secondary"
289
+ >
290
+ {role} override
291
+ </Badge>
292
+ );
293
+ })}
294
+ </div>
295
+ ) : null}
296
+
247
297
  <div className="mt-3 border-border/40 border-t pt-3 text-muted-foreground text-xs">
248
298
  Click card to browse documents in this collection.
249
299
  </div>
@@ -268,19 +318,46 @@ export default function Collections({ navigate }: PageProps) {
268
318
  );
269
319
  const [removing, setRemoving] = useState(false);
270
320
  const [addDialogOpen, setAddDialogOpen] = useState(false);
321
+ const [modelDialogCollection, setModelDialogCollection] =
322
+ useState<CollectionStats | null>(null);
271
323
  const [initialCollectionPath, setInitialCollectionPath] = useState<
272
324
  string | undefined
273
325
  >(undefined);
274
326
 
275
327
  const loadCollections = useCallback(async () => {
276
- const { data, error: err } = await apiFetch<StatusResponse>("/api/status");
277
- if (err) {
278
- setError(err);
279
- } else if (data) {
280
- setCollections(data.collections);
281
- setOnboarding(data.onboarding);
282
- setError(null);
328
+ const [statusResult, collectionsResult] = await Promise.all([
329
+ apiFetch<StatusResponse>("/api/status"),
330
+ apiFetch<CollectionsResponseItem[]>("/api/collections"),
331
+ ]);
332
+
333
+ if (statusResult.error) {
334
+ setError(statusResult.error);
335
+ return;
336
+ }
337
+
338
+ if (!statusResult.data) {
339
+ return;
283
340
  }
341
+
342
+ const collectionsByName = new Map(
343
+ (collectionsResult.data ?? []).map((item) => [item.name, item] as const)
344
+ );
345
+ const merged = statusResult.data.collections.map((collection) => {
346
+ const config = collectionsByName.get(collection.name);
347
+ return {
348
+ ...collection,
349
+ activePresetId: config?.activePresetId,
350
+ effectiveModels: config?.effectiveModels,
351
+ include: config?.include,
352
+ models: config?.models,
353
+ modelSources: config?.modelSources,
354
+ pattern: config?.pattern,
355
+ };
356
+ });
357
+
358
+ setCollections(merged);
359
+ setOnboarding(statusResult.data.onboarding);
360
+ setError(collectionsResult.error ?? null);
284
361
  }, []);
285
362
 
286
363
  // Initial load
@@ -500,6 +577,7 @@ export default function Collections({ navigate }: PageProps) {
500
577
  `/browse?collection=${encodeURIComponent(collection.name)}`
501
578
  )
502
579
  }
580
+ onModelSettings={() => setModelDialogCollection(collection)}
503
581
  onReindex={() => void handleReindex(collection.name)}
504
582
  onRemove={() => setRemoveDialog(collection)}
505
583
  />
@@ -516,6 +594,17 @@ export default function Collections({ navigate }: PageProps) {
516
594
  open={addDialogOpen}
517
595
  />
518
596
 
597
+ <CollectionModelDialog
598
+ collection={modelDialogCollection}
599
+ onOpenChange={(open) => {
600
+ if (!open) {
601
+ setModelDialogCollection(null);
602
+ }
603
+ }}
604
+ onSaved={() => void loadCollections()}
605
+ open={!!modelDialogCollection}
606
+ />
607
+
519
608
  {/* Remove confirmation dialog */}
520
609
  <Dialog
521
610
  onOpenChange={(open) => !open && setRemoveDialog(null)}
@@ -10,8 +10,12 @@ import { readdir } from "node:fs/promises";
10
10
  // node:path has no Bun equivalent
11
11
  import { posix as pathPosix } from "node:path";
12
12
 
13
- import type { Config, ModelPreset } from "../../config/types";
14
- import type { Collection } from "../../config/types";
13
+ import type {
14
+ Collection,
15
+ CollectionModelOverrides,
16
+ Config,
17
+ ModelPreset,
18
+ } from "../../config/types";
15
19
  import type {
16
20
  AskResult,
17
21
  Citation,
@@ -26,7 +30,11 @@ import type { StartJobError } from "../jobs";
26
30
  import type { CollectionWatchService } from "../watch-service";
27
31
 
28
32
  import { modelsPull } from "../../cli/commands/models/pull";
29
- import { addCollection, removeCollection } from "../../collection";
33
+ import {
34
+ addCollection,
35
+ removeCollection,
36
+ updateCollection,
37
+ } from "../../collection";
30
38
  import {
31
39
  buildEditableCopyContent,
32
40
  deriveEditableCopyRelPath,
@@ -68,7 +76,13 @@ import {
68
76
  import { validateRelPath } from "../../core/validation";
69
77
  import { defaultSyncService, type SyncResult } from "../../ingestion";
70
78
  import { updateFrontmatterTags } from "../../ingestion/frontmatter";
71
- import { getModelConfig, getPreset, listPresets } from "../../llm/registry";
79
+ import {
80
+ getCollectionEffectiveModels,
81
+ getCollectionModelSources,
82
+ getModelConfig,
83
+ getPreset,
84
+ listPresets,
85
+ } from "../../llm/registry";
72
86
  import {
73
87
  generateGroundedAnswer,
74
88
  processAnswerResult,
@@ -189,6 +203,59 @@ export interface CreateCollectionRequestBody {
189
203
  gitPull?: boolean;
190
204
  }
191
205
 
206
+ export interface UpdateCollectionRequestBody {
207
+ models?: {
208
+ embed?: string | null;
209
+ rerank?: string | null;
210
+ expand?: string | null;
211
+ gen?: string | null;
212
+ };
213
+ }
214
+
215
+ export interface CollectionResponse {
216
+ name: string;
217
+ path: string;
218
+ pattern: string;
219
+ include: string[];
220
+ exclude: string[];
221
+ updateCmd?: string;
222
+ languageHint?: string;
223
+ models?: CollectionModelOverrides;
224
+ effectiveModels: {
225
+ embed: string;
226
+ rerank: string;
227
+ expand: string;
228
+ gen: string;
229
+ };
230
+ modelSources: {
231
+ embed: "override" | "preset" | "default";
232
+ rerank: "override" | "preset" | "default";
233
+ expand: "override" | "preset" | "default";
234
+ gen: "override" | "preset" | "default";
235
+ };
236
+ activePresetId: string;
237
+ }
238
+
239
+ function serializeCollection(
240
+ config: Config,
241
+ collection: Collection
242
+ ): CollectionResponse {
243
+ const modelConfig = getModelConfig(config);
244
+ return {
245
+ name: collection.name,
246
+ path: collection.path,
247
+ pattern: collection.pattern,
248
+ include: collection.include,
249
+ exclude: collection.exclude,
250
+ updateCmd: collection.updateCmd,
251
+ languageHint: collection.languageHint,
252
+ models: collection.models,
253
+ effectiveModels: getCollectionEffectiveModels(config, collection.name),
254
+ modelSources: getCollectionModelSources(config, collection.name),
255
+ activePresetId: modelConfig.activePreset,
256
+ };
257
+ }
258
+
192
259
  export interface ImportPreviewRequestBody {
193
260
  path: string;
194
261
  name?: string;
@@ -630,19 +697,9 @@ export async function handleStatus(
630
697
  * GET /api/collections
631
698
  * Returns list of collections.
632
699
  */
633
- export async function handleCollections(
634
- store: SqliteAdapter
635
- ): Promise<Response> {
636
- const result = await store.getCollections();
637
- if (!result.ok) {
638
- return errorResponse("RUNTIME", result.error.message, 500);
639
- }
640
-
700
+ export async function handleCollections(config: Config): Promise<Response> {
641
701
  return jsonResponse(
642
- result.value.map((c) => ({
643
- name: c.name,
644
- path: c.path,
645
- }))
702
+ config.collections.map((c) => serializeCollection(config, c))
646
703
  );
647
704
  }
648
705
 
@@ -850,6 +907,86 @@ export async function handleDeleteCollection(
850
907
  });
851
908
  }
852
909
 
910
+ /**
911
+ * PATCH /api/collections/:name
912
+ * Update collection model overrides.
913
+ */
914
+ export async function handleUpdateCollection(
915
+ ctxHolder: ContextHolder,
916
+ store: SqliteAdapter,
917
+ name: string,
918
+ req: Request
919
+ ): Promise<Response> {
920
+ let body: UpdateCollectionRequestBody;
921
+ try {
922
+ body = (await req.json()) as UpdateCollectionRequestBody;
923
+ } catch {
924
+ return errorResponse("VALIDATION", "Invalid JSON body");
925
+ }
926
+
927
+ if (body.models !== undefined && typeof body.models !== "object") {
928
+ return errorResponse("VALIDATION", "models must be an object");
929
+ }
930
+ if (!body.models) {
931
+ return errorResponse("VALIDATION", "Missing models patch");
932
+ }
933
+
934
+ for (const [role, value] of Object.entries(body.models)) {
935
+ if (!["embed", "rerank", "expand", "gen"].includes(role)) {
936
+ return errorResponse("VALIDATION", `Unknown model role: ${role}`);
937
+ }
938
+ if (value !== undefined && value !== null && typeof value !== "string") {
939
+ return errorResponse("VALIDATION", `${role} must be a string or null`);
940
+ }
941
+ }
942
+
943
+ const syncResult = await applyConfigChangeTyped<Collection>(
944
+ ctxHolder,
945
+ store,
946
+ (cfg) => {
947
+ const result = updateCollection(cfg, {
948
+ name,
949
+ models: body.models,
950
+ });
951
+ if (!result.ok) {
952
+ return Promise.resolve({
953
+ ok: false as const,
954
+ error: result.message,
955
+ code: result.code,
956
+ });
957
+ }
958
+ return Promise.resolve({
959
+ ok: true as const,
960
+ config: result.config,
961
+ value: result.collection,
962
+ });
963
+ }
964
+ );
965
+
966
+ if (!syncResult.ok) {
967
+ const statusMap: Record<string, number> = {
968
+ NOT_FOUND: 404,
969
+ VALIDATION: 400,
970
+ };
971
+ const status = statusMap[syncResult.code] ?? 500;
972
+ return errorResponse(syncResult.code, syncResult.error, status);
973
+ }
974
+
975
+ const collection =
976
+ syncResult.value ??
977
+ syncResult.config.collections.find(
978
+ (item) => item.name === name.toLowerCase()
979
+ );
980
+ if (!collection) {
981
+ return errorResponse("RUNTIME", "Collection not found after update", 500);
982
+ }
983
+
984
+ return jsonResponse({
985
+ success: true,
986
+ collection: serializeCollection(syncResult.config, collection),
987
+ });
988
+ }
989
+
853
990
  /**
854
991
  * POST /api/sync
855
992
  * Trigger re-index of all or specific collection.
@@ -3577,7 +3714,7 @@ export async function routeApi(
3577
3714
  }
3578
3715
 
3579
3716
  if (path === "/api/collections") {
3580
- return handleCollections(store);
3717
+ return handleCollections(config);
3581
3718
  }
3582
3719
 
3583
3720
  if (path === "/api/docs") {
@@ -51,6 +51,7 @@ import {
51
51
  handleSync,
52
52
  handleTags,
53
53
  handleTrashDoc,
54
+ handleUpdateCollection,
54
55
  handleUpdateDoc,
55
56
  } from "./routes/api";
56
57
  import { handleGraph } from "./routes/graph";
@@ -192,7 +193,10 @@ export async function startServer(
192
193
  },
193
194
  "/api/collections": {
194
195
  GET: async () =>
195
- withSecurityHeaders(await handleCollections(store), isDev),
196
+ withSecurityHeaders(
197
+ await handleCollections(ctxHolder.config),
198
+ isDev
199
+ ),
196
200
  POST: async (req: Request) => {
197
201
  if (!isRequestAllowed(req, port)) {
198
202
  return withSecurityHeaders(forbiddenResponse(), isDev);
@@ -516,6 +520,19 @@ export async function startServer(
516
520
  withSecurityHeaders(handleEmbedStatus(ctxHolder.scheduler), isDev),
517
521
  },
518
522
  "/api/collections/:name": {
523
+ PATCH: async (req: Request) => {
524
+ if (!isRequestAllowed(req, port)) {
525
+ return withSecurityHeaders(forbiddenResponse(), isDev);
526
+ }
527
+ const url = new URL(req.url);
528
+ const name = decodeURIComponent(
529
+ url.pathname.split("/").pop() || ""
530
+ );
531
+ return withSecurityHeaders(
532
+ await handleUpdateCollection(ctxHolder, store, name, req),
533
+ isDev
534
+ );
535
+ },
519
536
  DELETE: async (req: Request) => {
520
537
  if (!isRequestAllowed(req, port)) {
521
538
  return withSecurityHeaders(forbiddenResponse(), isDev);