@gmickel/gno 0.18.0 → 0.19.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
@@ -32,7 +32,12 @@ GNO is a local knowledge engine that turns your documents into a searchable, con
32
32
 
33
33
  ---
34
34
 
35
- ## What's New in v0.18
35
+ ## What's New in v0.19
36
+
37
+ - **Exclusion Filters**: explicit `exclude` controls across CLI, API, Web, and MCP to hard-prune unwanted docs by title/path/body text
38
+ - **Ask Query-Mode Parity**: Ask now supports structured `term` / `intent` / `hyde` controls in both API and Web UI
39
+
40
+ ### v0.18
36
41
 
37
42
  - **Intent Steering**: optional `intent` control for ambiguous queries across CLI, API, Web, and MCP query flows
38
43
  - **Rerank Controls**: `candidateLimit` lets you tune rerank cost vs. recall on slower or memory-constrained machines
@@ -162,6 +167,7 @@ gno vsearch "error handling patterns" # Semantic similarity
162
167
  gno query "database optimization" # Full pipeline
163
168
  gno query "meeting decisions" --since "last month" --category "meeting,notes" --author "gordon"
164
169
  gno query "performance" --intent "web performance and latency"
170
+ gno query "performance" --exclude "reviews,hiring"
165
171
  gno ask "what did we decide" --answer # AI synthesis
166
172
  ```
167
173
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.18.0",
3
+ "version": "0.19.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",
@@ -199,6 +199,7 @@ export async function ask(
199
199
  author: options.author,
200
200
  tagsAll: options.tagsAll,
201
201
  tagsAny: options.tagsAny,
202
+ exclude: options.exclude,
202
203
  noExpand: options.noExpand,
203
204
  noRerank: options.noRerank,
204
205
  candidateLimit: options.candidateLimit,
@@ -262,6 +263,8 @@ export async function ask(
262
263
  vectorsUsed: searchResult.value.meta.vectorsUsed ?? false,
263
264
  intent: searchResult.value.meta.intent,
264
265
  candidateLimit: searchResult.value.meta.candidateLimit,
266
+ exclude: searchResult.value.meta.exclude,
267
+ queryModes: searchResult.value.meta.queryModes,
265
268
  answerGenerated,
266
269
  totalResults: results.length,
267
270
  answerContext,
@@ -225,6 +225,10 @@ function wireSearchCommands(program: Command): void {
225
225
  .option("--category <values>", "require category match (comma-separated)")
226
226
  .option("--author <text>", "filter by author (case-insensitive contains)")
227
227
  .option("--intent <text>", "disambiguating context for ambiguous queries")
228
+ .option(
229
+ "--exclude <values>",
230
+ "exclude docs containing any term (comma-separated)"
231
+ )
228
232
  .option("--tags-all <tags>", "require ALL tags (comma-separated)")
229
233
  .option("--tags-any <tags>", "require ANY tag (comma-separated)")
230
234
  .option("--full", "include full content")
@@ -270,6 +274,7 @@ function wireSearchCommands(program: Command): void {
270
274
  ? parsePositiveInt("limit", cmdOpts.limit)
271
275
  : getDefaultLimit(format);
272
276
  const categories = parseCsvValues(cmdOpts.category);
277
+ const exclude = parseCsvValues(cmdOpts.exclude);
273
278
 
274
279
  const { search, formatSearch } = await import("./commands/search");
275
280
  const result = await search(queryText, {
@@ -282,6 +287,7 @@ function wireSearchCommands(program: Command): void {
282
287
  categories,
283
288
  author: cmdOpts.author as string | undefined,
284
289
  intent: cmdOpts.intent as string | undefined,
290
+ exclude,
285
291
  tagsAll,
286
292
  tagsAny,
287
293
  full: Boolean(cmdOpts.full),
@@ -332,6 +338,10 @@ function wireSearchCommands(program: Command): void {
332
338
  .option("--category <values>", "require category match (comma-separated)")
333
339
  .option("--author <text>", "filter by author (case-insensitive contains)")
334
340
  .option("--intent <text>", "disambiguating context for ambiguous queries")
341
+ .option(
342
+ "--exclude <values>",
343
+ "exclude docs containing any term (comma-separated)"
344
+ )
335
345
  .option("--tags-all <tags>", "require ALL tags (comma-separated)")
336
346
  .option("--tags-any <tags>", "require ANY tag (comma-separated)")
337
347
  .option("--full", "include full content")
@@ -377,6 +387,7 @@ function wireSearchCommands(program: Command): void {
377
387
  ? parsePositiveInt("limit", cmdOpts.limit)
378
388
  : getDefaultLimit(format);
379
389
  const categories = parseCsvValues(cmdOpts.category);
390
+ const exclude = parseCsvValues(cmdOpts.exclude);
380
391
 
381
392
  const { vsearch, formatVsearch } = await import("./commands/vsearch");
382
393
  const result = await vsearch(queryText, {
@@ -389,6 +400,7 @@ function wireSearchCommands(program: Command): void {
389
400
  categories,
390
401
  author: cmdOpts.author as string | undefined,
391
402
  intent: cmdOpts.intent as string | undefined,
403
+ exclude,
392
404
  tagsAll,
393
405
  tagsAny,
394
406
  full: Boolean(cmdOpts.full),
@@ -434,6 +446,10 @@ function wireSearchCommands(program: Command): void {
434
446
  .option("--category <values>", "require category match (comma-separated)")
435
447
  .option("--author <text>", "filter by author (case-insensitive contains)")
436
448
  .option("--intent <text>", "disambiguating context for ambiguous queries")
449
+ .option(
450
+ "--exclude <values>",
451
+ "exclude docs containing any term (comma-separated)"
452
+ )
437
453
  .option("--tags-all <tags>", "require ALL tags (comma-separated)")
438
454
  .option("--tags-any <tags>", "require ANY tag (comma-separated)")
439
455
  .option("--full", "include full content")
@@ -505,6 +521,7 @@ function wireSearchCommands(program: Command): void {
505
521
  ? parsePositiveInt("candidate-limit", cmdOpts.candidateLimit)
506
522
  : undefined;
507
523
  const categories = parseCsvValues(cmdOpts.category);
524
+ const exclude = parseCsvValues(cmdOpts.exclude);
508
525
 
509
526
  // Determine expansion/rerank settings based on flags
510
527
  // Priority: --fast > --thorough > --no-expand/--no-rerank > default
@@ -541,6 +558,7 @@ function wireSearchCommands(program: Command): void {
541
558
  categories,
542
559
  author: cmdOpts.author as string | undefined,
543
560
  intent: cmdOpts.intent as string | undefined,
561
+ exclude,
544
562
  tagsAll,
545
563
  tagsAny,
546
564
  full: Boolean(cmdOpts.full),
@@ -586,6 +604,10 @@ function wireSearchCommands(program: Command): void {
586
604
  .option("--category <values>", "require category match (comma-separated)")
587
605
  .option("--author <text>", "filter by author (case-insensitive contains)")
588
606
  .option("--intent <text>", "disambiguating context for ambiguous queries")
607
+ .option(
608
+ "--exclude <values>",
609
+ "exclude docs containing any term (comma-separated)"
610
+ )
589
611
  .option("--fast", "skip expansion and reranking (fastest)")
590
612
  .option("--thorough", "enable query expansion (slower)")
591
613
  .option("-C, --candidate-limit <num>", "max candidates passed to reranking")
@@ -616,6 +638,7 @@ function wireSearchCommands(program: Command): void {
616
638
  ? parsePositiveInt("max-answer-tokens", cmdOpts.maxAnswerTokens)
617
639
  : undefined;
618
640
  const categories = parseCsvValues(cmdOpts.category);
641
+ const exclude = parseCsvValues(cmdOpts.exclude);
619
642
 
620
643
  // Determine expansion/rerank settings based on flags
621
644
  // Default: skip expansion (balanced mode)
@@ -641,6 +664,7 @@ function wireSearchCommands(program: Command): void {
641
664
  categories,
642
665
  author: cmdOpts.author as string | undefined,
643
666
  intent: cmdOpts.intent as string | undefined,
667
+ exclude,
644
668
  noExpand,
645
669
  noRerank,
646
670
  candidateLimit,
@@ -57,6 +57,7 @@ const searchInputSchema = z.object({
57
57
  minScore: z.number().min(0).max(1).optional(),
58
58
  lang: z.string().optional(),
59
59
  intent: z.string().optional(),
60
+ exclude: z.array(z.string()).optional(),
60
61
  since: z.string().optional(),
61
62
  until: z.string().optional(),
62
63
  categories: z.array(z.string()).optional(),
@@ -107,6 +108,7 @@ const vsearchInputSchema = z.object({
107
108
  minScore: z.number().min(0).max(1).optional(),
108
109
  lang: z.string().optional(),
109
110
  intent: z.string().optional(),
111
+ exclude: z.array(z.string()).optional(),
110
112
  since: z.string().optional(),
111
113
  until: z.string().optional(),
112
114
  categories: z.array(z.string()).optional(),
@@ -128,6 +130,7 @@ export const queryInputSchema = z.object({
128
130
  lang: z.string().optional(),
129
131
  intent: z.string().optional(),
130
132
  candidateLimit: z.number().int().min(1).max(100).optional(),
133
+ exclude: z.array(z.string()).optional(),
131
134
  since: z.string().optional(),
132
135
  until: z.string().optional(),
133
136
  categories: z.array(z.string()).optional(),
@@ -38,6 +38,7 @@ interface QueryInput {
38
38
  lang?: string;
39
39
  intent?: string;
40
40
  candidateLimit?: number;
41
+ exclude?: string[];
41
42
  since?: string;
42
43
  until?: string;
43
44
  categories?: string[];
@@ -251,6 +252,7 @@ export function handleQuery(
251
252
  queryLanguageHint: args.lang, // Affects expansion prompt, not retrieval
252
253
  intent: args.intent,
253
254
  candidateLimit: args.candidateLimit,
255
+ exclude: args.exclude,
254
256
  since: args.since,
255
257
  until: args.until,
256
258
  categories: args.categories,
@@ -20,6 +20,7 @@ interface SearchInput {
20
20
  minScore?: number;
21
21
  lang?: string;
22
22
  intent?: string;
23
+ exclude?: string[];
23
24
  since?: string;
24
25
  until?: string;
25
26
  categories?: string[];
@@ -110,6 +111,7 @@ export function handleSearch(
110
111
  collection: args.collection,
111
112
  lang: args.lang,
112
113
  intent: args.intent,
114
+ exclude: args.exclude,
113
115
  since: args.since,
114
116
  until: args.until,
115
117
  categories: args.categories,
@@ -29,6 +29,7 @@ interface VsearchInput {
29
29
  minScore?: number;
30
30
  lang?: string;
31
31
  intent?: string;
32
+ exclude?: string[];
32
33
  since?: string;
33
34
  until?: string;
34
35
  categories?: string[];
@@ -194,6 +195,7 @@ export function handleVsearch(
194
195
  minScore: args.minScore,
195
196
  collection: args.collection,
196
197
  intent: args.intent,
198
+ exclude: args.exclude,
197
199
  since: args.since,
198
200
  until: args.until,
199
201
  categories: args.categories,
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Explicit exclusion helpers for retrieval filters.
3
+ *
4
+ * @module src/pipeline/exclude
5
+ */
6
+
7
+ import type { ChunkRow } from "../store/types";
8
+
9
+ export function normalizeExcludeTerms(values: string[]): string[] {
10
+ const out: string[] = [];
11
+ const seen = new Set<string>();
12
+
13
+ for (const value of values) {
14
+ for (const part of value.split(",")) {
15
+ const trimmed = part.trim();
16
+ if (!trimmed) {
17
+ continue;
18
+ }
19
+ const key = trimmed.toLowerCase();
20
+ if (seen.has(key)) {
21
+ continue;
22
+ }
23
+ seen.add(key);
24
+ out.push(trimmed);
25
+ }
26
+ }
27
+
28
+ return out;
29
+ }
30
+
31
+ function includesTerm(haystack: string, term: string): boolean {
32
+ return haystack.toLowerCase().includes(term.toLowerCase());
33
+ }
34
+
35
+ export function matchesExcludedText(
36
+ haystacks: string[],
37
+ excludeTerms: string[] | undefined
38
+ ): boolean {
39
+ if (!excludeTerms?.length) {
40
+ return false;
41
+ }
42
+
43
+ for (const haystack of haystacks) {
44
+ if (!haystack) {
45
+ continue;
46
+ }
47
+ for (const term of excludeTerms) {
48
+ if (includesTerm(haystack, term)) {
49
+ return true;
50
+ }
51
+ }
52
+ }
53
+
54
+ return false;
55
+ }
56
+
57
+ export function matchesExcludedChunks(
58
+ chunks: ChunkRow[],
59
+ excludeTerms: string[] | undefined
60
+ ): boolean {
61
+ if (!excludeTerms?.length || chunks.length === 0) {
62
+ return false;
63
+ }
64
+
65
+ return matchesExcludedText(
66
+ chunks.map((chunk) => chunk.text),
67
+ excludeTerms
68
+ );
69
+ }
@@ -21,6 +21,7 @@ import type {
21
21
  import { err, ok } from "../store/types";
22
22
  import { createChunkLookup } from "./chunk-lookup";
23
23
  import { formatQueryForEmbedding } from "./contextual";
24
+ import { matchesExcludedChunks, matchesExcludedText } from "./exclude";
24
25
  import { expandQuery } from "./expansion";
25
26
  import {
26
27
  buildExplainResults,
@@ -670,6 +671,25 @@ export async function searchHybrid(
670
671
  continue;
671
672
  }
672
673
 
674
+ const excluded =
675
+ matchesExcludedText(
676
+ [
677
+ doc.title ?? "",
678
+ doc.relPath,
679
+ doc.author ?? "",
680
+ doc.contentType ?? "",
681
+ ...(doc.categories ?? []),
682
+ ],
683
+ options.exclude
684
+ ) ||
685
+ matchesExcludedChunks(
686
+ chunksMap.get(candidate.mirrorHash) ?? [],
687
+ options.exclude
688
+ );
689
+ if (excluded) {
690
+ continue;
691
+ }
692
+
673
693
  // For --full mode, de-dupe by docid (keep best scoring candidate per doc)
674
694
  if (options.full && seenDocids.has(doc.docid)) {
675
695
  continue;
@@ -810,6 +830,7 @@ export async function searchHybrid(
810
830
  vectorsUsed: vectorAvailable,
811
831
  totalResults: finalResults.length,
812
832
  intent: options.intent,
833
+ exclude: options.exclude,
813
834
  collection: options.collection,
814
835
  lang: options.lang,
815
836
  since: temporalRange.since,
@@ -17,6 +17,7 @@ import type {
17
17
 
18
18
  import { err, ok } from "../store/types";
19
19
  import { createChunkLookup } from "./chunk-lookup";
20
+ import { matchesExcludedChunks, matchesExcludedText } from "./exclude";
20
21
  import { selectBestChunkForSteering } from "./intent";
21
22
  import { detectQueryLanguage } from "./query-language";
22
23
  import {
@@ -237,6 +238,21 @@ export async function searchBm25(
237
238
  ) ?? rawChunk)
238
239
  : rawChunk;
239
240
 
241
+ const excluded =
242
+ matchesExcludedText(
243
+ [fts.title ?? "", fts.relPath ?? "", fts.snippet ?? ""],
244
+ options.exclude
245
+ ) ||
246
+ matchesExcludedChunks(
247
+ chunksMapResult.ok && fts.mirrorHash
248
+ ? (chunksMapResult.value.get(fts.mirrorHash) ?? [])
249
+ : [],
250
+ options.exclude
251
+ );
252
+ if (excluded) {
253
+ continue;
254
+ }
255
+
240
256
  // For --full, de-dupe by docid (keep best scoring chunk per doc)
241
257
  // Raw BM25: smaller (more negative) is better
242
258
  if (options.full) {
@@ -309,6 +325,7 @@ export async function searchBm25(
309
325
  mode: "bm25",
310
326
  totalResults: Math.min(filteredResults.length, limit),
311
327
  intent: options.intent,
328
+ exclude: options.exclude,
312
329
  collection: options.collection,
313
330
  lang: options.lang,
314
331
  since: temporalRange.since,
@@ -79,6 +79,8 @@ export interface SearchMeta {
79
79
  author?: string;
80
80
  /** Rerank candidate limit used */
81
81
  candidateLimit?: number;
82
+ /** Explicit exclusion terms applied */
83
+ exclude?: string[];
82
84
  /** Explain data (when --explain is used) */
83
85
  explain?: {
84
86
  lines: ExplainLine[];
@@ -124,6 +126,8 @@ export interface SearchOptions {
124
126
  author?: string;
125
127
  /** Optional disambiguating context that steers scoring/snippets, but is not searched directly */
126
128
  intent?: string;
129
+ /** Explicit exclusion terms for hard candidate pruning */
130
+ exclude?: string[];
127
131
  }
128
132
 
129
133
  /** Structured query mode identifier */
@@ -317,6 +321,8 @@ export interface AskMeta {
317
321
  vectorsUsed: boolean;
318
322
  intent?: string;
319
323
  candidateLimit?: number;
324
+ exclude?: string[];
325
+ queryModes?: QueryModeSummary;
320
326
  answerGenerated?: boolean;
321
327
  totalResults?: number;
322
328
  answerContext?: AnswerContextExplain;
@@ -14,6 +14,7 @@ import type { SearchOptions, SearchResult, SearchResults } from "./types";
14
14
  import { err, ok } from "../store/types";
15
15
  import { createChunkLookup } from "./chunk-lookup";
16
16
  import { formatQueryForEmbedding } from "./contextual";
17
+ import { matchesExcludedChunks, matchesExcludedText } from "./exclude";
17
18
  import { selectBestChunkForSteering } from "./intent";
18
19
  import { detectQueryLanguage } from "./query-language";
19
20
  import {
@@ -174,6 +175,25 @@ export async function searchVectorWithEmbedding(
174
175
  continue;
175
176
  }
176
177
 
178
+ const excluded =
179
+ matchesExcludedText(
180
+ [
181
+ doc.title ?? "",
182
+ doc.relPath,
183
+ doc.author ?? "",
184
+ doc.contentType ?? "",
185
+ ...(doc.categories ?? []),
186
+ ],
187
+ options.exclude
188
+ ) ||
189
+ matchesExcludedChunks(
190
+ chunksMap.get(vec.mirrorHash) ?? [],
191
+ options.exclude
192
+ );
193
+ if (excluded) {
194
+ continue;
195
+ }
196
+
177
197
  // For --full, de-dupe by docid (keep best scoring chunk per doc)
178
198
  if (options.full) {
179
199
  const existing = bestByDocid.get(doc.docid);
@@ -301,6 +321,7 @@ export async function searchVectorWithEmbedding(
301
321
  vectorsUsed: true,
302
322
  totalResults: finalResults.length,
303
323
  intent: options.intent,
324
+ exclude: options.exclude,
304
325
  collection: options.collection,
305
326
  lang: options.lang,
306
327
  since: temporalRange.since,
@@ -362,6 +383,9 @@ interface DocumentInfo {
362
383
  title: string | null;
363
384
  collection: string;
364
385
  relPath: string;
386
+ author: string | null;
387
+ contentType: string | null;
388
+ categories: string[] | null;
365
389
  sourceHash: string;
366
390
  sourceMime: string;
367
391
  sourceExt: string;
@@ -491,6 +515,9 @@ async function buildDocumentMap(
491
515
  title: doc.title,
492
516
  collection: doc.collection,
493
517
  relPath: doc.relPath,
518
+ author: doc.author ?? null,
519
+ contentType: doc.contentType ?? null,
520
+ categories: doc.categories ?? null,
494
521
  sourceHash: doc.sourceHash,
495
522
  sourceMime: doc.sourceMime,
496
523
  sourceExt: doc.sourceExt,
@@ -11,6 +11,7 @@ export interface RetrievalFiltersState {
11
11
  collection: string;
12
12
  intent: string;
13
13
  candidateLimit: string;
14
+ exclude: string;
14
15
  since: string;
15
16
  until: string;
16
17
  category: string;
@@ -126,6 +127,7 @@ export function parseFiltersFromSearch(
126
127
  intent: params.get("intent") ?? defaults.intent ?? "",
127
128
  candidateLimit:
128
129
  params.get("candidateLimit") ?? defaults.candidateLimit ?? "",
130
+ exclude: params.get("exclude") ?? defaults.exclude ?? "",
129
131
  since: params.get("since") ?? defaults.since ?? "",
130
132
  until: params.get("until") ?? defaults.until ?? "",
131
133
  category: params.get("category") ?? defaults.category ?? "",
@@ -152,6 +154,7 @@ export function applyFiltersToUrl(
152
154
  setOrDelete("collection", filters.collection);
153
155
  setOrDelete("intent", filters.intent);
154
156
  setOrDelete("candidateLimit", filters.candidateLimit);
157
+ setOrDelete("exclude", filters.exclude);
155
158
  setOrDelete("since", filters.since);
156
159
  setOrDelete("until", filters.until);
157
160
  setOrDelete("category", filters.category);
@@ -6,6 +6,7 @@ import {
6
6
  FileText,
7
7
  SlidersHorizontal,
8
8
  Sparkles,
9
+ XIcon,
9
10
  } from "lucide-react";
10
11
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11
12
 
@@ -40,7 +41,12 @@ import {
40
41
  import { Textarea } from "../components/ui/textarea";
41
42
  import { apiFetch } from "../hooks/use-api";
42
43
  import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
43
- import { parseTagsCsv, type TagMode } from "../lib/retrieval-filters";
44
+ import {
45
+ parseTagsCsv,
46
+ type QueryModeEntry,
47
+ type QueryModeType,
48
+ type TagMode,
49
+ } from "../lib/retrieval-filters";
44
50
  import { cn } from "../lib/utils";
45
51
 
46
52
  interface PageProps {
@@ -79,6 +85,11 @@ interface AskResponse {
79
85
  vectorsUsed: boolean;
80
86
  answerGenerated: boolean;
81
87
  totalResults: number;
88
+ queryModes?: {
89
+ term: number;
90
+ intent: number;
91
+ hyde: boolean;
92
+ };
82
93
  };
83
94
  }
84
95
 
@@ -103,6 +114,12 @@ interface Collection {
103
114
 
104
115
  const THOROUGHNESS_ORDER: Thoroughness[] = ["fast", "balanced", "thorough"];
105
116
 
117
+ const QUERY_MODE_LABEL: Record<QueryModeType, string> = {
118
+ term: "Term",
119
+ intent: "Intent",
120
+ hyde: "HyDE",
121
+ };
122
+
106
123
  /**
107
124
  * Render answer text with clickable citation badges.
108
125
  * Citations like [1] become clickable to navigate to source.
@@ -166,12 +183,17 @@ export default function Ask({ navigate }: PageProps) {
166
183
  const [selectedCollection, setSelectedCollection] = useState("");
167
184
  const [intent, setIntent] = useState("");
168
185
  const [candidateLimit, setCandidateLimit] = useState("");
186
+ const [exclude, setExclude] = useState("");
169
187
  const [since, setSince] = useState("");
170
188
  const [until, setUntil] = useState("");
171
189
  const [category, setCategory] = useState("");
172
190
  const [author, setAuthor] = useState("");
173
191
  const [tagMode, setTagMode] = useState<TagMode>("any");
174
192
  const [tagsInput, setTagsInput] = useState("");
193
+ const [queryModes, setQueryModes] = useState<QueryModeEntry[]>([]);
194
+ const [queryModeDraft, setQueryModeDraft] = useState<QueryModeType>("term");
195
+ const [queryModeText, setQueryModeText] = useState("");
196
+ const [queryModeError, setQueryModeError] = useState<string | null>(null);
175
197
 
176
198
  const messagesEndRef = useRef<HTMLDivElement>(null);
177
199
  const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -220,6 +242,28 @@ export default function Ask({ navigate }: PageProps) {
220
242
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
221
243
  }, [conversation]);
222
244
 
245
+ const handleAddQueryMode = useCallback(() => {
246
+ const text = queryModeText.trim();
247
+ if (!text) {
248
+ return;
249
+ }
250
+ if (
251
+ queryModeDraft === "hyde" &&
252
+ queryModes.some((queryMode) => queryMode.mode === "hyde")
253
+ ) {
254
+ setQueryModeError("Only one HyDE mode is allowed.");
255
+ return;
256
+ }
257
+ setQueryModes((prev) => [...prev, { mode: queryModeDraft, text }]);
258
+ setQueryModeText("");
259
+ setQueryModeError(null);
260
+ }, [queryModeDraft, queryModeText, queryModes]);
261
+
262
+ const handleRemoveQueryMode = useCallback((index: number) => {
263
+ setQueryModes((prev) => prev.filter((_, i) => i !== index));
264
+ setQueryModeError(null);
265
+ }, []);
266
+
223
267
  const handleSubmit = useCallback(
224
268
  async (e: React.FormEvent) => {
225
269
  e.preventDefault();
@@ -250,6 +294,9 @@ export default function Ask({ navigate }: PageProps) {
250
294
  if (candidateLimit.trim()) {
251
295
  requestBody.candidateLimit = Number(candidateLimit);
252
296
  }
297
+ if (exclude.trim()) {
298
+ requestBody.exclude = exclude.trim();
299
+ }
253
300
  if (since) {
254
301
  requestBody.since = since;
255
302
  }
@@ -282,6 +329,9 @@ export default function Ask({ navigate }: PageProps) {
282
329
  requestBody.noExpand = false;
283
330
  requestBody.noRerank = false;
284
331
  }
332
+ if (queryModes.length > 0) {
333
+ requestBody.queryModes = queryModes;
334
+ }
285
335
 
286
336
  const { data, error } = await apiFetch<AskResponse>("/api/ask", {
287
337
  method: "POST",
@@ -305,8 +355,10 @@ export default function Ask({ navigate }: PageProps) {
305
355
  author,
306
356
  candidateLimit,
307
357
  category,
358
+ exclude,
308
359
  intent,
309
360
  query,
361
+ queryModes,
310
362
  selectedCollection,
311
363
  since,
312
364
  tagMode,
@@ -327,12 +379,16 @@ export default function Ask({ navigate }: PageProps) {
327
379
  setSelectedCollection("");
328
380
  setIntent("");
329
381
  setCandidateLimit("");
382
+ setExclude("");
330
383
  setSince("");
331
384
  setUntil("");
332
385
  setCategory("");
333
386
  setAuthor("");
334
387
  setTagsInput("");
335
388
  setTagMode("any");
389
+ setQueryModes([]);
390
+ setQueryModeText("");
391
+ setQueryModeError(null);
336
392
  };
337
393
 
338
394
  const answerAvailable = capabilities?.answer ?? false;
@@ -341,6 +397,8 @@ export default function Ask({ navigate }: PageProps) {
341
397
  selectedCollection ? `collection:${selectedCollection}` : null,
342
398
  intent.trim() ? `intent:${intent.trim()}` : null,
343
399
  candidateLimit.trim() ? `candidates:${candidateLimit.trim()}` : null,
400
+ exclude.trim() ? `exclude:${exclude.trim()}` : null,
401
+ queryModes.length > 0 ? `${queryModes.length} query mode(s)` : null,
344
402
  since ? `since:${since}` : null,
345
403
  until ? `until:${until}` : null,
346
404
  category.trim() ? `category:${category.trim()}` : null,
@@ -466,6 +524,17 @@ export default function Ask({ navigate }: PageProps) {
466
524
  />
467
525
  </div>
468
526
 
527
+ <div className="md:col-span-2">
528
+ <p className="mb-1 text-muted-foreground text-xs">
529
+ Exclude
530
+ </p>
531
+ <Input
532
+ onChange={(e) => setExclude(e.target.value)}
533
+ placeholder="team reviews, hiring, onboarding"
534
+ value={exclude}
535
+ />
536
+ </div>
537
+
469
538
  <div>
470
539
  <p className="mb-1 text-muted-foreground text-xs">
471
540
  Category
@@ -556,6 +625,75 @@ export default function Ask({ navigate }: PageProps) {
556
625
  Clear filters
557
626
  </Button>
558
627
  </div>
628
+
629
+ <div className="space-y-2">
630
+ <p className="text-muted-foreground text-xs">
631
+ Query modes (term, intent, hyde)
632
+ </p>
633
+ <div className="flex flex-wrap items-center gap-2">
634
+ <Select
635
+ onValueChange={(value) =>
636
+ setQueryModeDraft(value as QueryModeType)
637
+ }
638
+ value={queryModeDraft}
639
+ >
640
+ <SelectTrigger className="w-[120px]">
641
+ <SelectValue />
642
+ </SelectTrigger>
643
+ <SelectContent>
644
+ <SelectItem value="term">Term</SelectItem>
645
+ <SelectItem value="intent">Intent</SelectItem>
646
+ <SelectItem value="hyde">HyDE</SelectItem>
647
+ </SelectContent>
648
+ </Select>
649
+ <Input
650
+ className="min-w-[220px] flex-1"
651
+ onChange={(e) => setQueryModeText(e.target.value)}
652
+ onKeyDown={(e) => {
653
+ if (e.key === "Enter") {
654
+ e.preventDefault();
655
+ handleAddQueryMode();
656
+ }
657
+ }}
658
+ placeholder="Add query mode text"
659
+ value={queryModeText}
660
+ />
661
+ <Button
662
+ onClick={handleAddQueryMode}
663
+ size="sm"
664
+ type="button"
665
+ variant="outline"
666
+ >
667
+ Add mode
668
+ </Button>
669
+ </div>
670
+
671
+ {queryModeError && (
672
+ <p className="text-destructive text-xs">
673
+ {queryModeError}
674
+ </p>
675
+ )}
676
+
677
+ {queryModes.length > 0 && (
678
+ <div className="flex flex-wrap gap-2">
679
+ {queryModes.map((queryMode, index) => (
680
+ <button
681
+ className={cn(
682
+ "group inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10",
683
+ "px-2.5 py-1 font-mono text-[11px] text-primary transition-all duration-150",
684
+ "hover:border-primary/50 hover:bg-primary/20"
685
+ )}
686
+ key={`${queryMode.mode}:${queryMode.text}:${index}`}
687
+ onClick={() => handleRemoveQueryMode(index)}
688
+ type="button"
689
+ >
690
+ <span>{`${QUERY_MODE_LABEL[queryMode.mode]}: ${queryMode.text}`}</span>
691
+ <XIcon className="size-3 opacity-60 transition-opacity group-hover:opacity-100" />
692
+ </button>
693
+ ))}
694
+ </div>
695
+ )}
696
+ </div>
559
697
  </CardContent>
560
698
  </Card>
561
699
  </CollapsibleContent>
@@ -683,6 +821,17 @@ export default function Ask({ navigate }: PageProps) {
683
821
  expanded
684
822
  </Badge>
685
823
  )}
824
+ {entry.response.meta.queryModes &&
825
+ (entry.response.meta.queryModes.term > 0 ||
826
+ entry.response.meta.queryModes.intent > 0 ||
827
+ entry.response.meta.queryModes.hyde) && (
828
+ <Badge
829
+ className="font-mono text-[9px]"
830
+ variant="outline"
831
+ >
832
+ query modes
833
+ </Badge>
834
+ )}
686
835
  </div>
687
836
 
688
837
  {!entry.response.answer &&
@@ -155,6 +155,7 @@ export default function Search({ navigate }: PageProps) {
155
155
  initialFilters.collection ||
156
156
  initialFilters.intent ||
157
157
  initialFilters.candidateLimit ||
158
+ initialFilters.exclude ||
158
159
  initialFilters.since ||
159
160
  initialFilters.until ||
160
161
  initialFilters.category ||
@@ -172,6 +173,7 @@ export default function Search({ navigate }: PageProps) {
172
173
  const [candidateLimit, setCandidateLimit] = useState(
173
174
  initialFilters.candidateLimit
174
175
  );
176
+ const [exclude, setExclude] = useState(initialFilters.exclude);
175
177
  const [since, setSince] = useState(initialFilters.since);
176
178
  const [until, setUntil] = useState(initialFilters.until);
177
179
  const [category, setCategory] = useState(initialFilters.category);
@@ -196,6 +198,7 @@ export default function Search({ navigate }: PageProps) {
196
198
  collection: selectedCollection,
197
199
  intent,
198
200
  candidateLimit,
201
+ exclude,
199
202
  since,
200
203
  until,
201
204
  category,
@@ -210,6 +213,7 @@ export default function Search({ navigate }: PageProps) {
210
213
  author,
211
214
  candidateLimit,
212
215
  category,
216
+ exclude,
213
217
  intent,
214
218
  queryModes,
215
219
  selectedCollection,
@@ -314,6 +318,9 @@ export default function Search({ navigate }: PageProps) {
314
318
  if (candidateLimit.trim()) {
315
319
  body.candidateLimit = Number(candidateLimit);
316
320
  }
321
+ if (exclude.trim()) {
322
+ body.exclude = exclude.trim();
323
+ }
317
324
  if (since) {
318
325
  body.since = since;
319
326
  }
@@ -373,6 +380,7 @@ export default function Search({ navigate }: PageProps) {
373
380
  author,
374
381
  candidateLimit,
375
382
  category,
383
+ exclude,
376
384
  intent,
377
385
  query,
378
386
  queryModes,
@@ -395,6 +403,7 @@ export default function Search({ navigate }: PageProps) {
395
403
  author,
396
404
  candidateLimit,
397
405
  category,
406
+ exclude,
398
407
  intent,
399
408
  queryModes,
400
409
  selectedCollection,
@@ -414,6 +423,7 @@ export default function Search({ navigate }: PageProps) {
414
423
  selectedCollection ? `collection:${selectedCollection}` : null,
415
424
  intent.trim() ? `intent:${intent.trim()}` : null,
416
425
  candidateLimit.trim() ? `candidates:${candidateLimit.trim()}` : null,
426
+ exclude.trim() ? `exclude:${exclude.trim()}` : null,
417
427
  since ? `since:${since}` : null,
418
428
  until ? `until:${until}` : null,
419
429
  category.trim() ? `category:${category.trim()}` : null,
@@ -428,6 +438,7 @@ export default function Search({ navigate }: PageProps) {
428
438
  setSelectedCollection("");
429
439
  setIntent("");
430
440
  setCandidateLimit("");
441
+ setExclude("");
431
442
  setSince("");
432
443
  setUntil("");
433
444
  setCategory("");
@@ -591,6 +602,17 @@ export default function Search({ navigate }: PageProps) {
591
602
  />
592
603
  </div>
593
604
 
605
+ <div className="md:col-span-2">
606
+ <p className="mb-1 text-muted-foreground text-xs">
607
+ Exclude
608
+ </p>
609
+ <Input
610
+ onChange={(e) => setExclude(e.target.value)}
611
+ placeholder="team reviews, hiring, onboarding"
612
+ value={exclude}
613
+ />
614
+ </div>
615
+
594
616
  <div>
595
617
  <p className="mb-1 text-muted-foreground text-xs">
596
618
  Category
@@ -67,6 +67,7 @@ export interface SearchRequestBody {
67
67
  minScore?: number;
68
68
  collection?: string;
69
69
  intent?: string;
70
+ exclude?: string;
70
71
  since?: string;
71
72
  until?: string;
72
73
  /** Comma-separated category filters */
@@ -86,6 +87,7 @@ export interface QueryRequestBody {
86
87
  lang?: string;
87
88
  intent?: string;
88
89
  candidateLimit?: number;
90
+ exclude?: string;
89
91
  since?: string;
90
92
  until?: string;
91
93
  /** Comma-separated category filters */
@@ -107,6 +109,8 @@ export interface AskRequestBody {
107
109
  lang?: string;
108
110
  intent?: string;
109
111
  candidateLimit?: number;
112
+ exclude?: string;
113
+ queryModes?: QueryModeInput[];
110
114
  since?: string;
111
115
  until?: string;
112
116
  /** Comma-separated category filters */
@@ -174,6 +178,73 @@ function parseCommaSeparatedValues(input: string): string[] {
174
178
  );
175
179
  }
176
180
 
181
+ function parseQueryModesInput(value: unknown): {
182
+ queryModes?: QueryModeInput[];
183
+ error?: Response;
184
+ } {
185
+ if (value === undefined) {
186
+ return {};
187
+ }
188
+
189
+ if (!Array.isArray(value)) {
190
+ return {
191
+ error: errorResponse(
192
+ "VALIDATION",
193
+ "queryModes must be an array of { mode, text } objects"
194
+ ),
195
+ };
196
+ }
197
+
198
+ const queryModes: QueryModeInput[] = [];
199
+ let hydeCount = 0;
200
+
201
+ for (const [index, entry] of value.entries()) {
202
+ if (!entry || typeof entry !== "object") {
203
+ return {
204
+ error: errorResponse(
205
+ "VALIDATION",
206
+ `queryModes[${index}] must be an object`
207
+ ),
208
+ };
209
+ }
210
+
211
+ const mode = (entry as { mode?: unknown }).mode;
212
+ const text = (entry as { text?: unknown }).text;
213
+ if (mode !== "term" && mode !== "intent" && mode !== "hyde") {
214
+ return {
215
+ error: errorResponse(
216
+ "VALIDATION",
217
+ `queryModes[${index}].mode must be one of: term, intent, hyde`
218
+ ),
219
+ };
220
+ }
221
+ if (typeof text !== "string" || !text.trim()) {
222
+ return {
223
+ error: errorResponse(
224
+ "VALIDATION",
225
+ `queryModes[${index}].text must be a non-empty string`
226
+ ),
227
+ };
228
+ }
229
+
230
+ if (mode === "hyde") {
231
+ hydeCount += 1;
232
+ if (hydeCount > 1) {
233
+ return {
234
+ error: errorResponse(
235
+ "VALIDATION",
236
+ "Only one hyde mode is allowed in queryModes"
237
+ ),
238
+ };
239
+ }
240
+ }
241
+
242
+ queryModes.push({ mode, text: text.trim() });
243
+ }
244
+
245
+ return { queryModes };
246
+ }
247
+
177
248
  // ─────────────────────────────────────────────────────────────────────────────
178
249
  // Route Handlers
179
250
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1101,6 +1172,12 @@ export async function handleSearch(
1101
1172
  if (body.intent !== undefined && typeof body.intent !== "string") {
1102
1173
  return errorResponse("VALIDATION", "intent must be a string");
1103
1174
  }
1175
+ if (body.exclude !== undefined && typeof body.exclude !== "string") {
1176
+ return errorResponse(
1177
+ "VALIDATION",
1178
+ "exclude must be a comma-separated string"
1179
+ );
1180
+ }
1104
1181
  if (body.category !== undefined && typeof body.category !== "string") {
1105
1182
  return errorResponse(
1106
1183
  "VALIDATION",
@@ -1140,6 +1217,9 @@ export async function handleSearch(
1140
1217
  const categories = body.category
1141
1218
  ? parseCommaSeparatedValues(body.category)
1142
1219
  : undefined;
1220
+ const exclude = body.exclude
1221
+ ? parseCommaSeparatedValues(body.exclude)
1222
+ : undefined;
1143
1223
  const author = body.author?.trim() || undefined;
1144
1224
 
1145
1225
  // Only BM25 supported in web UI (vector/hybrid require LLM ports)
@@ -1148,6 +1228,7 @@ export async function handleSearch(
1148
1228
  minScore: body.minScore,
1149
1229
  collection: body.collection,
1150
1230
  intent: body.intent?.trim() || undefined,
1231
+ exclude,
1151
1232
  tagsAll,
1152
1233
  tagsAny,
1153
1234
  since: body.since,
@@ -1220,6 +1301,12 @@ export async function handleQuery(
1220
1301
  if (body.intent !== undefined && typeof body.intent !== "string") {
1221
1302
  return errorResponse("VALIDATION", "intent must be a string");
1222
1303
  }
1304
+ if (body.exclude !== undefined && typeof body.exclude !== "string") {
1305
+ return errorResponse(
1306
+ "VALIDATION",
1307
+ "exclude must be a comma-separated string"
1308
+ );
1309
+ }
1223
1310
  if (
1224
1311
  body.candidateLimit !== undefined &&
1225
1312
  (typeof body.candidateLimit !== "number" || body.candidateLimit < 1)
@@ -1239,54 +1326,11 @@ export async function handleQuery(
1239
1326
  return errorResponse("VALIDATION", "author must be a string");
1240
1327
  }
1241
1328
 
1242
- // Validate queryModes
1243
- let queryModes: QueryModeInput[] | undefined;
1244
- if (body.queryModes !== undefined) {
1245
- if (!Array.isArray(body.queryModes)) {
1246
- return errorResponse(
1247
- "VALIDATION",
1248
- "queryModes must be an array of { mode, text } objects"
1249
- );
1250
- }
1251
-
1252
- queryModes = [];
1253
- let hydeCount = 0;
1254
-
1255
- for (const [index, entry] of body.queryModes.entries()) {
1256
- if (!entry || typeof entry !== "object") {
1257
- return errorResponse(
1258
- "VALIDATION",
1259
- `queryModes[${index}] must be an object`
1260
- );
1261
- }
1262
-
1263
- const mode = (entry as { mode?: unknown }).mode;
1264
- const text = (entry as { text?: unknown }).text;
1265
- if (mode !== "term" && mode !== "intent" && mode !== "hyde") {
1266
- return errorResponse(
1267
- "VALIDATION",
1268
- `queryModes[${index}].mode must be one of: term, intent, hyde`
1269
- );
1270
- }
1271
- if (typeof text !== "string" || !text.trim()) {
1272
- return errorResponse(
1273
- "VALIDATION",
1274
- `queryModes[${index}].text must be a non-empty string`
1275
- );
1276
- }
1277
-
1278
- if (mode === "hyde") {
1279
- hydeCount += 1;
1280
- if (hydeCount > 1) {
1281
- return errorResponse(
1282
- "VALIDATION",
1283
- "Only one hyde mode is allowed in queryModes"
1284
- );
1285
- }
1286
- }
1287
-
1288
- queryModes.push({ mode, text: text.trim() });
1289
- }
1329
+ const { queryModes, error: queryModesError } = parseQueryModesInput(
1330
+ body.queryModes
1331
+ );
1332
+ if (queryModesError) {
1333
+ return queryModesError;
1290
1334
  }
1291
1335
 
1292
1336
  // Parse tag filters
@@ -1318,6 +1362,9 @@ export async function handleQuery(
1318
1362
  const categories = body.category
1319
1363
  ? parseCommaSeparatedValues(body.category)
1320
1364
  : undefined;
1365
+ const exclude = body.exclude
1366
+ ? parseCommaSeparatedValues(body.exclude)
1367
+ : undefined;
1321
1368
  const author = body.author?.trim() || undefined;
1322
1369
 
1323
1370
  const result = await searchHybrid(
@@ -1340,6 +1387,7 @@ export async function handleQuery(
1340
1387
  body.candidateLimit !== undefined
1341
1388
  ? Math.min(body.candidateLimit, 100)
1342
1389
  : undefined,
1390
+ exclude,
1343
1391
  queryModes,
1344
1392
  noExpand: body.noExpand,
1345
1393
  noRerank: body.noRerank,
@@ -1406,6 +1454,12 @@ export async function handleAsk(
1406
1454
  if (body.intent !== undefined && typeof body.intent !== "string") {
1407
1455
  return errorResponse("VALIDATION", "intent must be a string");
1408
1456
  }
1457
+ if (body.exclude !== undefined && typeof body.exclude !== "string") {
1458
+ return errorResponse(
1459
+ "VALIDATION",
1460
+ "exclude must be a comma-separated string"
1461
+ );
1462
+ }
1409
1463
  if (
1410
1464
  body.candidateLimit !== undefined &&
1411
1465
  (typeof body.candidateLimit !== "number" || body.candidateLimit < 1)
@@ -1425,6 +1479,13 @@ export async function handleAsk(
1425
1479
  return errorResponse("VALIDATION", "author must be a string");
1426
1480
  }
1427
1481
 
1482
+ const { queryModes, error: queryModesError } = parseQueryModesInput(
1483
+ body.queryModes
1484
+ );
1485
+ if (queryModesError) {
1486
+ return queryModesError;
1487
+ }
1488
+
1428
1489
  if (body.tagsAll) {
1429
1490
  try {
1430
1491
  tagsAll = parseAndValidateTagFilter(body.tagsAll);
@@ -1450,6 +1511,9 @@ export async function handleAsk(
1450
1511
  const categories = body.category
1451
1512
  ? parseCommaSeparatedValues(body.category)
1452
1513
  : undefined;
1514
+ const exclude = body.exclude
1515
+ ? parseCommaSeparatedValues(body.exclude)
1516
+ : undefined;
1453
1517
  const author = body.author?.trim() || undefined;
1454
1518
 
1455
1519
  const limit = Math.min(body.limit ?? 5, 20);
@@ -1476,6 +1540,8 @@ export async function handleAsk(
1476
1540
  body.candidateLimit !== undefined
1477
1541
  ? Math.min(body.candidateLimit, 100)
1478
1542
  : undefined,
1543
+ exclude,
1544
+ queryModes,
1479
1545
  tagsAll,
1480
1546
  tagsAny,
1481
1547
  since: body.since,
@@ -1528,6 +1594,8 @@ export async function handleAsk(
1528
1594
  vectorsUsed: searchResult.value.meta.vectorsUsed ?? false,
1529
1595
  intent: searchResult.value.meta.intent,
1530
1596
  candidateLimit: searchResult.value.meta.candidateLimit,
1597
+ exclude: searchResult.value.meta.exclude,
1598
+ queryModes: searchResult.value.meta.queryModes,
1531
1599
  answerGenerated,
1532
1600
  totalResults: results.length,
1533
1601
  answerContext,