@gmickel/gno 0.22.6 → 0.24.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
@@ -24,6 +24,7 @@ GNO is a local knowledge engine that turns your documents into a searchable, con
24
24
  - [Agent Integration](#agent-integration)
25
25
  - [Web UI](#web-ui)
26
26
  - [REST API](#rest-api)
27
+ - [SDK](#sdk)
27
28
  - [How It Works](#how-it-works)
28
29
  - [Features](#features)
29
30
  - [Local Models](#local-models)
@@ -33,7 +34,20 @@ GNO is a local knowledge engine that turns your documents into a searchable, con
33
34
 
34
35
  ---
35
36
 
36
- ## What's New in v0.22
37
+ ## What's New in v0.24
38
+
39
+ - **Structured Query Documents**: first-class multi-line query syntax using `term:`, `intent:`, and `hyde:`
40
+ - **Cross-Surface Rollout**: works across CLI, API, MCP, SDK, and Web Search/Ask
41
+ - **Portable Retrieval Prompts**: save/share advanced retrieval intent as one text payload instead of repeated flags or JSON arrays
42
+
43
+ ### v0.23
44
+
45
+ - **SDK / Library Mode**: package-root importable SDK with `createGnoClient(...)` for direct retrieval, document access, and indexing flows
46
+ - **Inline Config Support**: embed GNO in another app without writing YAML config files
47
+ - **Programmatic Indexing**: call `update`, `embed`, and `index` directly from Bun/TypeScript
48
+ - **Docs & Website**: dedicated SDK guide, feature page, homepage section, and architecture docs
49
+
50
+ ### v0.22
37
51
 
38
52
  - **Promoted Slim Retrieval Model**: published `slim-retrieval-v1` on Hugging Face for direct `hf:` installation in GNO
39
53
  - **Fine-Tuning Workflow**: local MLX LoRA training, portable GGUF export, automatic checkpoint selection, promotion bundles, and repeatable benchmark comparisons
@@ -187,6 +201,58 @@ gno skill install --target all # Both Claude + Codex
187
201
 
188
202
  ---
189
203
 
204
+ ## SDK
205
+
206
+ Embed GNO directly in another Bun or TypeScript app. No CLI subprocesses. No local server required.
207
+
208
+ ```ts
209
+ import { createDefaultConfig, createGnoClient } from "@gmickel/gno";
210
+
211
+ const config = createDefaultConfig();
212
+ config.collections = [
213
+ {
214
+ name: "notes",
215
+ path: "/Users/me/notes",
216
+ pattern: "**/*",
217
+ include: [],
218
+ exclude: [],
219
+ },
220
+ ];
221
+
222
+ const client = await createGnoClient({
223
+ config,
224
+ dbPath: "/tmp/gno-sdk.sqlite",
225
+ });
226
+
227
+ await client.index({ noEmbed: true });
228
+
229
+ const results = await client.query("JWT token flow", {
230
+ noExpand: true,
231
+ noRerank: true,
232
+ });
233
+
234
+ console.log(results.results[0]?.uri);
235
+ await client.close();
236
+ ```
237
+
238
+ Core SDK surface:
239
+
240
+ - `createGnoClient({ config | configPath, dbPath? })`
241
+ - `search`, `vsearch`, `query`, `ask`
242
+ - `get`, `multiGet`, `list`, `status`
243
+ - `update`, `embed`, `index`
244
+ - `close`
245
+
246
+ Install in an app:
247
+
248
+ ```bash
249
+ bun add @gmickel/gno
250
+ ```
251
+
252
+ Full guide: [SDK docs](https://gno.sh/docs/SDK/)
253
+
254
+ ---
255
+
190
256
  ## Search Modes
191
257
 
192
258
  | Command | Mode | Best For |
@@ -228,11 +294,15 @@ gno query "auth flow" \
228
294
  --query-mode intent:"how refresh token rotation works" \
229
295
  --query-mode hyde:"Refresh tokens rotate on each use and previous tokens are revoked." \
230
296
  --explain
297
+
298
+ # Multi-line structured query document
299
+ gno query $'auth flow\nterm: "refresh token" -oauth1\nintent: how refresh token rotation works\nhyde: Refresh tokens rotate on each use and previous tokens are revoked.' --fast
231
300
  ```
232
301
 
233
302
  - Modes: `term` (BM25-focused), `intent` (semantic-focused), `hyde` (single hypothetical passage)
234
303
  - Explain includes stage timings, fallback/cache counters, and per-result score components
235
304
  - `gno ask --json` includes `meta.answerContext` for adaptive source selection traces
305
+ - Search and Ask web text boxes also accept multi-line structured query documents with `Shift+Enter`
236
306
 
237
307
  ---
238
308
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.22.6",
3
+ "version": "0.24.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",
@@ -33,7 +33,19 @@
33
33
  "vendor"
34
34
  ],
35
35
  "type": "module",
36
- "module": "src/index.ts",
36
+ "main": "src/sdk/index.ts",
37
+ "module": "src/sdk/index.ts",
38
+ "types": "src/sdk/index.ts",
39
+ "exports": {
40
+ ".": {
41
+ "types": "./src/sdk/index.ts",
42
+ "default": "./src/sdk/index.ts"
43
+ },
44
+ "./cli": {
45
+ "default": "./src/index.ts"
46
+ },
47
+ "./package.json": "./package.json"
48
+ },
37
49
  "publishConfig": {
38
50
  "access": "public"
39
51
  },
@@ -497,6 +497,21 @@ function wireSearchCommands(program: Command): void {
497
497
  queryModes = parsed.value;
498
498
  }
499
499
 
500
+ const { normalizeStructuredQueryInput } =
501
+ await import("../core/structured-query");
502
+ const normalizedInput = normalizeStructuredQueryInput(
503
+ queryText,
504
+ queryModes ?? []
505
+ );
506
+ if (!normalizedInput.ok) {
507
+ throw new CliError("VALIDATION", normalizedInput.error.message);
508
+ }
509
+ queryText = normalizedInput.value.query;
510
+ queryModes =
511
+ normalizedInput.value.queryModes.length > 0
512
+ ? normalizedInput.value.queryModes
513
+ : undefined;
514
+
500
515
  // Parse and validate tag filters
501
516
  let tagsAll: string[] | undefined;
502
517
  let tagsAny: string[] | undefined;
@@ -656,6 +671,21 @@ function wireSearchCommands(program: Command): void {
656
671
  queryModes = parsed.value;
657
672
  }
658
673
 
674
+ const { normalizeStructuredQueryInput } =
675
+ await import("../core/structured-query");
676
+ const normalizedInput = normalizeStructuredQueryInput(
677
+ queryText,
678
+ queryModes ?? []
679
+ );
680
+ if (!normalizedInput.ok) {
681
+ throw new CliError("VALIDATION", normalizedInput.error.message);
682
+ }
683
+ queryText = normalizedInput.value.query;
684
+ queryModes =
685
+ normalizedInput.value.queryModes.length > 0
686
+ ? normalizedInput.value.queryModes
687
+ : undefined;
688
+
659
689
  // Determine expansion/rerank settings based on flags
660
690
  // Default: skip expansion (balanced mode)
661
691
  let noExpand = true;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Structured multi-line query document parsing.
3
+ *
4
+ * Pure parser used across CLI, API, MCP, SDK, and Web.
5
+ *
6
+ * @module src/core/structured-query
7
+ */
8
+
9
+ import type { QueryModeInput } from "../pipeline/types";
10
+
11
+ export interface StructuredQueryError {
12
+ line: number | null;
13
+ message: string;
14
+ }
15
+
16
+ export interface StructuredQueryNormalization {
17
+ query: string;
18
+ queryModes: QueryModeInput[];
19
+ usedStructuredQuerySyntax: boolean;
20
+ derivedQuery: boolean;
21
+ }
22
+
23
+ export type StructuredQueryResult =
24
+ | { ok: true; value: StructuredQueryNormalization }
25
+ | { ok: false; error: StructuredQueryError };
26
+
27
+ const RECOGNIZED_MODE_PREFIXES = new Set(["term", "intent", "hyde"]);
28
+ const ANY_PREFIX_PATTERN = /^\s*([a-z][a-z0-9_-]*)\s*:\s*(.*)$/i;
29
+ const RECOGNIZED_PREFIX_PATTERN = /^\s*(term|intent|hyde)\s*:\s*(.*)$/i;
30
+
31
+ function buildError(
32
+ message: string,
33
+ line: number | null
34
+ ): StructuredQueryResult {
35
+ return { ok: false, error: { message, line } };
36
+ }
37
+
38
+ function trimNonBlankLines(query: string): string[] {
39
+ return query.split(/\r?\n/).filter((line) => line.trim().length > 0);
40
+ }
41
+
42
+ /**
43
+ * Parse multi-line structured query syntax.
44
+ *
45
+ * Rules:
46
+ * - single-line queries remain unchanged
47
+ * - blank lines are ignored
48
+ * - recognized typed lines: term:, intent:, hyde:
49
+ * - if structured syntax is used, unknown prefix lines like foo:bar are rejected
50
+ * - untyped lines contribute to the base query text
51
+ * - if no untyped lines exist, base query is derived from term lines first, then intent lines
52
+ * - hyde-only documents are rejected
53
+ */
54
+ export function normalizeStructuredQueryInput(
55
+ query: string,
56
+ explicitQueryModes: QueryModeInput[] = []
57
+ ): StructuredQueryResult {
58
+ if (!query.includes("\n")) {
59
+ return {
60
+ ok: true,
61
+ value: {
62
+ query,
63
+ queryModes: explicitQueryModes,
64
+ usedStructuredQuerySyntax: false,
65
+ derivedQuery: false,
66
+ },
67
+ };
68
+ }
69
+
70
+ const lines = trimNonBlankLines(query);
71
+ if (lines.length === 0) {
72
+ return {
73
+ ok: true,
74
+ value: {
75
+ query,
76
+ queryModes: explicitQueryModes,
77
+ usedStructuredQuerySyntax: false,
78
+ derivedQuery: false,
79
+ },
80
+ };
81
+ }
82
+
83
+ const hasRecognizedTypedLine = lines.some((line) => {
84
+ const match = line.match(RECOGNIZED_PREFIX_PATTERN);
85
+ return Boolean(match?.[1]);
86
+ });
87
+
88
+ if (!hasRecognizedTypedLine) {
89
+ return {
90
+ ok: true,
91
+ value: {
92
+ query,
93
+ queryModes: explicitQueryModes,
94
+ usedStructuredQuerySyntax: false,
95
+ derivedQuery: false,
96
+ },
97
+ };
98
+ }
99
+
100
+ const queryModes: QueryModeInput[] = [];
101
+ const bodyLines: string[] = [];
102
+ let hydeCount = 0;
103
+
104
+ for (const [index, line] of query.split(/\r?\n/).entries()) {
105
+ const trimmed = line.trim();
106
+ if (trimmed.length === 0) {
107
+ continue;
108
+ }
109
+
110
+ const recognized = trimmed.match(RECOGNIZED_PREFIX_PATTERN);
111
+ if (recognized) {
112
+ const mode = recognized[1]?.toLowerCase() as QueryModeInput["mode"];
113
+ const text = recognized[2]?.trim() ?? "";
114
+ if (text.length === 0) {
115
+ return buildError(
116
+ `Structured query line ${index + 1} must contain non-empty text after ${mode}:`,
117
+ index + 1
118
+ );
119
+ }
120
+ if (mode === "hyde") {
121
+ hydeCount += 1;
122
+ if (hydeCount > 1) {
123
+ return buildError(
124
+ "Only one hyde line is allowed in a structured query document.",
125
+ index + 1
126
+ );
127
+ }
128
+ }
129
+ queryModes.push({ mode, text });
130
+ continue;
131
+ }
132
+
133
+ const prefixed = trimmed.match(ANY_PREFIX_PATTERN);
134
+ if (prefixed?.[1]) {
135
+ const prefix = prefixed[1].toLowerCase();
136
+ if (!RECOGNIZED_MODE_PREFIXES.has(prefix)) {
137
+ return buildError(
138
+ `Unknown structured query line prefix "${prefix}:" on line ${index + 1}. Expected term:, intent:, or hyde:.`,
139
+ index + 1
140
+ );
141
+ }
142
+ }
143
+
144
+ bodyLines.push(trimmed);
145
+ }
146
+
147
+ const combinedQueryModes = [...queryModes, ...explicitQueryModes];
148
+ const totalHydeCount = combinedQueryModes.filter(
149
+ (entry) => entry.mode === "hyde"
150
+ ).length;
151
+ if (totalHydeCount > 1) {
152
+ return buildError(
153
+ "Only one hyde entry is allowed across structured query syntax and explicit query modes.",
154
+ null
155
+ );
156
+ }
157
+
158
+ let normalizedQuery = bodyLines.join(" ").trim();
159
+ let derivedQuery = false;
160
+
161
+ if (!normalizedQuery) {
162
+ const termQuery = queryModes
163
+ .filter((entry) => entry.mode === "term")
164
+ .map((entry) => entry.text)
165
+ .join(" ")
166
+ .trim();
167
+ const intentQuery = queryModes
168
+ .filter((entry) => entry.mode === "intent")
169
+ .map((entry) => entry.text)
170
+ .join(" ")
171
+ .trim();
172
+
173
+ normalizedQuery = termQuery || intentQuery;
174
+ derivedQuery = normalizedQuery.length > 0;
175
+ }
176
+
177
+ if (!normalizedQuery) {
178
+ return buildError(
179
+ "Structured query documents must include at least one plain query line, term line, or intent line. hyde-only documents are not allowed.",
180
+ null
181
+ );
182
+ }
183
+
184
+ return {
185
+ ok: true,
186
+ value: {
187
+ query: normalizedQuery,
188
+ queryModes: combinedQueryModes,
189
+ usedStructuredQuerySyntax: true,
190
+ derivedQuery,
191
+ },
192
+ };
193
+ }
194
+
195
+ export function hasStructuredQuerySyntax(query: string): boolean {
196
+ const result = normalizeStructuredQueryInput(query);
197
+ return result.ok && result.value.usedStructuredQuerySyntax;
198
+ }
@@ -20,6 +20,7 @@ import type { ToolContext } from "../server";
20
20
 
21
21
  import { parseUri } from "../../app/constants";
22
22
  import { createNonTtyProgressRenderer } from "../../cli/progress";
23
+ import { normalizeStructuredQueryInput } from "../../core/structured-query";
23
24
  import { LlmAdapter } from "../../llm/nodeLlamaCpp/adapter";
24
25
  import { resolveDownloadPolicy } from "../../llm/policy";
25
26
  import { getActivePreset } from "../../llm/registry";
@@ -143,6 +144,19 @@ export function handleQuery(
143
144
  }
144
145
  }
145
146
 
147
+ const normalizedInput = normalizeStructuredQueryInput(
148
+ args.query,
149
+ args.queryModes ?? []
150
+ );
151
+ if (!normalizedInput.ok) {
152
+ throw new Error(normalizedInput.error.message);
153
+ }
154
+ const queryText = normalizedInput.value.query;
155
+ const queryModes =
156
+ normalizedInput.value.queryModes.length > 0
157
+ ? normalizedInput.value.queryModes
158
+ : undefined;
159
+
146
160
  const preset = getActivePreset(ctx.config);
147
161
  const llm = new LlmAdapter(ctx.config);
148
162
 
@@ -170,7 +184,7 @@ export function handleQuery(
170
184
  // Determine noExpand/noRerank based on mode flags
171
185
  // Priority: fast > thorough > expand/rerank params > defaults
172
186
  // Default: noExpand=true (skip expansion), noRerank=false (with reranking)
173
- const hasStructuredModes = Boolean(args.queryModes?.length);
187
+ const hasStructuredModes = Boolean(queryModes?.length);
174
188
  let noExpand = true;
175
189
  let noRerank = false;
176
190
 
@@ -245,7 +259,7 @@ export function handleQuery(
245
259
  // Note: per spec, lang is a "hint" for query, not a filter
246
260
  // Pass as queryLanguageHint to affect expansion prompt selection
247
261
  // but NOT retrieval filtering (that would be options.lang)
248
- const result = await searchHybrid(deps, args.query, {
262
+ const result = await searchHybrid(deps, queryText, {
249
263
  limit: args.limit ?? 5,
250
264
  minScore: args.minScore,
251
265
  collection: args.collection,
@@ -259,7 +273,7 @@ export function handleQuery(
259
273
  author: args.author,
260
274
  noExpand,
261
275
  noRerank,
262
- queryModes: args.queryModes,
276
+ queryModes,
263
277
  tagsAll: normalizeTagFilters(args.tagsAll),
264
278
  tagsAny: normalizeTagFilters(args.tagsAny),
265
279
  });
@@ -46,26 +46,31 @@ export function parseQueryModeSpecs(
46
46
  specs: string[]
47
47
  ): StoreResult<QueryModeInput[]> {
48
48
  const parsed: QueryModeInput[] = [];
49
- let hydeCount = 0;
50
-
51
49
  for (const spec of specs) {
52
50
  const entry = parseQueryModeSpec(spec);
53
51
  if (!entry.ok) {
54
52
  return entry;
55
53
  }
56
- if (entry.value.mode === "hyde") {
57
- hydeCount += 1;
58
- if (hydeCount > 1) {
59
- return err(
60
- "INVALID_INPUT",
61
- "Only one hyde mode is allowed in structured query input."
62
- );
63
- }
64
- }
65
54
  parsed.push(entry.value);
66
55
  }
67
56
 
68
- return ok(parsed);
57
+ return validateQueryModes(parsed);
58
+ }
59
+
60
+ /**
61
+ * Validate normalized query mode objects.
62
+ */
63
+ export function validateQueryModes(
64
+ queryModes: QueryModeInput[]
65
+ ): StoreResult<QueryModeInput[]> {
66
+ const hydeCount = queryModes.filter((entry) => entry.mode === "hyde").length;
67
+ if (hydeCount > 1) {
68
+ return err(
69
+ "INVALID_INPUT",
70
+ "Only one hyde mode is allowed in structured query input."
71
+ );
72
+ }
73
+ return ok(queryModes);
69
74
  }
70
75
 
71
76
  /**