@gmickel/gno 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +256 -0
  2. package/assets/skill/SKILL.md +112 -0
  3. package/assets/skill/cli-reference.md +327 -0
  4. package/assets/skill/examples.md +234 -0
  5. package/assets/skill/mcp-reference.md +159 -0
  6. package/package.json +90 -0
  7. package/src/app/constants.ts +313 -0
  8. package/src/cli/colors.ts +65 -0
  9. package/src/cli/commands/ask.ts +545 -0
  10. package/src/cli/commands/cleanup.ts +105 -0
  11. package/src/cli/commands/collection/add.ts +120 -0
  12. package/src/cli/commands/collection/index.ts +10 -0
  13. package/src/cli/commands/collection/list.ts +108 -0
  14. package/src/cli/commands/collection/remove.ts +64 -0
  15. package/src/cli/commands/collection/rename.ts +95 -0
  16. package/src/cli/commands/context/add.ts +67 -0
  17. package/src/cli/commands/context/check.ts +153 -0
  18. package/src/cli/commands/context/index.ts +10 -0
  19. package/src/cli/commands/context/list.ts +109 -0
  20. package/src/cli/commands/context/rm.ts +52 -0
  21. package/src/cli/commands/doctor.ts +393 -0
  22. package/src/cli/commands/embed.ts +462 -0
  23. package/src/cli/commands/get.ts +356 -0
  24. package/src/cli/commands/index-cmd.ts +119 -0
  25. package/src/cli/commands/index.ts +102 -0
  26. package/src/cli/commands/init.ts +328 -0
  27. package/src/cli/commands/ls.ts +217 -0
  28. package/src/cli/commands/mcp/config.ts +300 -0
  29. package/src/cli/commands/mcp/index.ts +24 -0
  30. package/src/cli/commands/mcp/install.ts +203 -0
  31. package/src/cli/commands/mcp/paths.ts +470 -0
  32. package/src/cli/commands/mcp/status.ts +222 -0
  33. package/src/cli/commands/mcp/uninstall.ts +158 -0
  34. package/src/cli/commands/mcp.ts +20 -0
  35. package/src/cli/commands/models/clear.ts +103 -0
  36. package/src/cli/commands/models/index.ts +32 -0
  37. package/src/cli/commands/models/list.ts +214 -0
  38. package/src/cli/commands/models/path.ts +51 -0
  39. package/src/cli/commands/models/pull.ts +199 -0
  40. package/src/cli/commands/models/use.ts +85 -0
  41. package/src/cli/commands/multi-get.ts +400 -0
  42. package/src/cli/commands/query.ts +220 -0
  43. package/src/cli/commands/ref-parser.ts +108 -0
  44. package/src/cli/commands/reset.ts +191 -0
  45. package/src/cli/commands/search.ts +136 -0
  46. package/src/cli/commands/shared.ts +156 -0
  47. package/src/cli/commands/skill/index.ts +19 -0
  48. package/src/cli/commands/skill/install.ts +197 -0
  49. package/src/cli/commands/skill/paths-cmd.ts +81 -0
  50. package/src/cli/commands/skill/paths.ts +191 -0
  51. package/src/cli/commands/skill/show.ts +73 -0
  52. package/src/cli/commands/skill/uninstall.ts +141 -0
  53. package/src/cli/commands/status.ts +205 -0
  54. package/src/cli/commands/update.ts +68 -0
  55. package/src/cli/commands/vsearch.ts +188 -0
  56. package/src/cli/context.ts +64 -0
  57. package/src/cli/errors.ts +64 -0
  58. package/src/cli/format/search-results.ts +211 -0
  59. package/src/cli/options.ts +183 -0
  60. package/src/cli/program.ts +1330 -0
  61. package/src/cli/run.ts +213 -0
  62. package/src/cli/ui.ts +92 -0
  63. package/src/config/defaults.ts +20 -0
  64. package/src/config/index.ts +55 -0
  65. package/src/config/loader.ts +161 -0
  66. package/src/config/paths.ts +87 -0
  67. package/src/config/saver.ts +153 -0
  68. package/src/config/types.ts +280 -0
  69. package/src/converters/adapters/markitdownTs/adapter.ts +140 -0
  70. package/src/converters/adapters/officeparser/adapter.ts +126 -0
  71. package/src/converters/canonicalize.ts +89 -0
  72. package/src/converters/errors.ts +218 -0
  73. package/src/converters/index.ts +51 -0
  74. package/src/converters/mime.ts +163 -0
  75. package/src/converters/native/markdown.ts +115 -0
  76. package/src/converters/native/plaintext.ts +56 -0
  77. package/src/converters/path.ts +48 -0
  78. package/src/converters/pipeline.ts +159 -0
  79. package/src/converters/registry.ts +74 -0
  80. package/src/converters/types.ts +123 -0
  81. package/src/converters/versions.ts +24 -0
  82. package/src/index.ts +27 -0
  83. package/src/ingestion/chunker.ts +238 -0
  84. package/src/ingestion/index.ts +32 -0
  85. package/src/ingestion/language.ts +276 -0
  86. package/src/ingestion/sync.ts +671 -0
  87. package/src/ingestion/types.ts +219 -0
  88. package/src/ingestion/walker.ts +235 -0
  89. package/src/llm/cache.ts +467 -0
  90. package/src/llm/errors.ts +191 -0
  91. package/src/llm/index.ts +58 -0
  92. package/src/llm/nodeLlamaCpp/adapter.ts +133 -0
  93. package/src/llm/nodeLlamaCpp/embedding.ts +165 -0
  94. package/src/llm/nodeLlamaCpp/generation.ts +88 -0
  95. package/src/llm/nodeLlamaCpp/lifecycle.ts +317 -0
  96. package/src/llm/nodeLlamaCpp/rerank.ts +94 -0
  97. package/src/llm/registry.ts +86 -0
  98. package/src/llm/types.ts +129 -0
  99. package/src/mcp/resources/index.ts +151 -0
  100. package/src/mcp/server.ts +229 -0
  101. package/src/mcp/tools/get.ts +220 -0
  102. package/src/mcp/tools/index.ts +160 -0
  103. package/src/mcp/tools/multi-get.ts +263 -0
  104. package/src/mcp/tools/query.ts +226 -0
  105. package/src/mcp/tools/search.ts +119 -0
  106. package/src/mcp/tools/status.ts +81 -0
  107. package/src/mcp/tools/vsearch.ts +198 -0
  108. package/src/pipeline/chunk-lookup.ts +44 -0
  109. package/src/pipeline/expansion.ts +256 -0
  110. package/src/pipeline/explain.ts +115 -0
  111. package/src/pipeline/fusion.ts +185 -0
  112. package/src/pipeline/hybrid.ts +535 -0
  113. package/src/pipeline/index.ts +64 -0
  114. package/src/pipeline/query-language.ts +118 -0
  115. package/src/pipeline/rerank.ts +223 -0
  116. package/src/pipeline/search.ts +261 -0
  117. package/src/pipeline/types.ts +328 -0
  118. package/src/pipeline/vsearch.ts +348 -0
  119. package/src/store/index.ts +41 -0
  120. package/src/store/migrations/001-initial.ts +196 -0
  121. package/src/store/migrations/index.ts +20 -0
  122. package/src/store/migrations/runner.ts +187 -0
  123. package/src/store/sqlite/adapter.ts +1242 -0
  124. package/src/store/sqlite/index.ts +7 -0
  125. package/src/store/sqlite/setup.ts +129 -0
  126. package/src/store/sqlite/types.ts +28 -0
  127. package/src/store/types.ts +506 -0
  128. package/src/store/vector/index.ts +13 -0
  129. package/src/store/vector/sqlite-vec.ts +373 -0
  130. package/src/store/vector/stats.ts +152 -0
  131. package/src/store/vector/types.ts +115 -0
@@ -0,0 +1,226 @@
1
+ /**
2
+ * MCP gno_query tool - Hybrid search with expansion and reranking.
3
+ *
4
+ * @module src/mcp/tools/query
5
+ */
6
+
7
+ import { join as pathJoin } from 'node:path';
8
+ import { parseUri } from '../../app/constants';
9
+ import { LlmAdapter } from '../../llm/nodeLlamaCpp/adapter';
10
+ import { getActivePreset } from '../../llm/registry';
11
+ import type {
12
+ EmbeddingPort,
13
+ GenerationPort,
14
+ RerankPort,
15
+ } from '../../llm/types';
16
+ import { type HybridSearchDeps, searchHybrid } from '../../pipeline/hybrid';
17
+ import type { SearchResult, SearchResults } from '../../pipeline/types';
18
+ import {
19
+ createVectorIndexPort,
20
+ type VectorIndexPort,
21
+ } from '../../store/vector';
22
+ import type { ToolContext } from '../server';
23
+ import { runTool, type ToolResult } from './index';
24
+
25
+ interface QueryInput {
26
+ query: string;
27
+ collection?: string;
28
+ limit?: number;
29
+ minScore?: number;
30
+ lang?: string;
31
+ expand?: boolean;
32
+ rerank?: boolean;
33
+ }
34
+
35
+ /**
36
+ * Enrich results with absPath derived from each result's URI.
37
+ */
38
+ function enrichWithAbsPath(
39
+ results: SearchResult[],
40
+ ctx: ToolContext
41
+ ): SearchResult[] {
42
+ return results.map((r) => {
43
+ const parsed = parseUri(r.uri);
44
+ if (!parsed) {
45
+ return r;
46
+ }
47
+
48
+ const collection = ctx.collections.find(
49
+ (c) => c.name === parsed.collection
50
+ );
51
+ if (!collection) {
52
+ return r;
53
+ }
54
+
55
+ return {
56
+ ...r,
57
+ source: {
58
+ ...r.source,
59
+ absPath: pathJoin(collection.path, r.source.relPath),
60
+ },
61
+ };
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Format search results as text for MCP content.
67
+ */
68
+ function formatSearchResults(data: SearchResults): string {
69
+ if (data.results.length === 0) {
70
+ return `No results found for "${data.meta.query}"`;
71
+ }
72
+
73
+ const lines: string[] = [];
74
+ const mode = data.meta.mode === 'bm25_only' ? 'BM25 only' : 'hybrid';
75
+ const flags: string[] = [];
76
+ if (data.meta.expanded) {
77
+ flags.push('expanded');
78
+ }
79
+ if (data.meta.reranked) {
80
+ flags.push('reranked');
81
+ }
82
+ if (data.meta.vectorsUsed) {
83
+ flags.push('vectors');
84
+ }
85
+
86
+ lines.push(
87
+ `Found ${data.results.length} results for "${data.meta.query}" (${mode}${flags.length > 0 ? `, ${flags.join(', ')}` : ''}):`
88
+ );
89
+ lines.push('');
90
+
91
+ for (const r of data.results) {
92
+ lines.push(`[${r.docid}] ${r.uri} (score: ${r.score.toFixed(3)})`);
93
+ if (r.title) {
94
+ lines.push(` Title: ${r.title}`);
95
+ }
96
+ if (r.snippet) {
97
+ const snippetPreview = r.snippet.slice(0, 200).replace(/\n/g, ' ');
98
+ lines.push(` ${snippetPreview}${r.snippet.length > 200 ? '...' : ''}`);
99
+ }
100
+ lines.push('');
101
+ }
102
+
103
+ return lines.join('\n');
104
+ }
105
+
106
+ /**
107
+ * Handle gno_query tool call.
108
+ */
109
+ export function handleQuery(
110
+ args: QueryInput,
111
+ ctx: ToolContext
112
+ ): Promise<ToolResult> {
113
+ return runTool(
114
+ ctx,
115
+ 'gno_query',
116
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: query with validation, hybrid search, and result formatting
117
+ async () => {
118
+ // Validate collection exists if specified
119
+ if (args.collection) {
120
+ const exists = ctx.collections.some((c) => c.name === args.collection);
121
+ if (!exists) {
122
+ throw new Error(`Collection not found: ${args.collection}`);
123
+ }
124
+ }
125
+
126
+ const preset = getActivePreset(ctx.config);
127
+ const llm = new LlmAdapter(ctx.config);
128
+
129
+ let embedPort: EmbeddingPort | null = null;
130
+ let genPort: GenerationPort | null = null;
131
+ let rerankPort: RerankPort | null = null;
132
+ let vectorIndex: VectorIndexPort | null = null;
133
+
134
+ try {
135
+ // Create embedding port (for vector search) - optional
136
+ const embedResult = await llm.createEmbeddingPort(preset.embed);
137
+ if (embedResult.ok) {
138
+ embedPort = embedResult.value;
139
+ }
140
+
141
+ // Create generation port (for expansion) - optional
142
+ // expand defaults to true per spec
143
+ if (args.expand !== false) {
144
+ const genResult = await llm.createGenerationPort(preset.gen);
145
+ if (genResult.ok) {
146
+ genPort = genResult.value;
147
+ }
148
+ }
149
+
150
+ // Create rerank port - optional
151
+ // rerank defaults to true per spec
152
+ if (args.rerank !== false) {
153
+ const rerankResult = await llm.createRerankPort(preset.rerank);
154
+ if (rerankResult.ok) {
155
+ rerankPort = rerankResult.value;
156
+ }
157
+ }
158
+
159
+ // Create vector index (optional)
160
+ if (embedPort) {
161
+ const embedInitResult = await embedPort.init();
162
+ if (embedInitResult.ok) {
163
+ const dimensions = embedPort.dimensions();
164
+ const db = ctx.store.getRawDb();
165
+ const vectorResult = await createVectorIndexPort(db, {
166
+ model: preset.embed,
167
+ dimensions,
168
+ });
169
+ if (vectorResult.ok) {
170
+ vectorIndex = vectorResult.value;
171
+ }
172
+ }
173
+ }
174
+
175
+ const deps: HybridSearchDeps = {
176
+ store: ctx.store,
177
+ config: ctx.config,
178
+ vectorIndex,
179
+ embedPort,
180
+ genPort,
181
+ rerankPort,
182
+ };
183
+
184
+ // Note: per spec, lang is a "hint" for query, not a filter
185
+ // Pass as queryLanguageHint to affect expansion prompt selection
186
+ // but NOT retrieval filtering (that would be options.lang)
187
+ const result = await searchHybrid(deps, args.query, {
188
+ limit: args.limit ?? 5,
189
+ minScore: args.minScore,
190
+ collection: args.collection,
191
+ queryLanguageHint: args.lang, // Affects expansion prompt, not retrieval
192
+ noExpand: args.expand === false,
193
+ noRerank: args.rerank === false,
194
+ });
195
+
196
+ if (!result.ok) {
197
+ throw new Error(result.error.message);
198
+ }
199
+
200
+ // Enrich with absPath
201
+ const enrichedResults = enrichWithAbsPath(result.value.results, ctx);
202
+
203
+ return {
204
+ ...result.value,
205
+ results: enrichedResults,
206
+ meta: {
207
+ ...result.value.meta,
208
+ // Add queryLanguage hint if provided
209
+ ...(args.lang ? { queryLanguage: args.lang } : {}),
210
+ },
211
+ };
212
+ } finally {
213
+ if (embedPort) {
214
+ await embedPort.dispose();
215
+ }
216
+ if (genPort) {
217
+ await genPort.dispose();
218
+ }
219
+ if (rerankPort) {
220
+ await rerankPort.dispose();
221
+ }
222
+ }
223
+ },
224
+ formatSearchResults
225
+ );
226
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * MCP gno_search tool - BM25 full-text search.
3
+ *
4
+ * @module src/mcp/tools/search
5
+ */
6
+
7
+ import { join as pathJoin } from 'node:path';
8
+ import { parseUri } from '../../app/constants';
9
+ import { searchBm25 } from '../../pipeline/search';
10
+ import type { SearchResult, SearchResults } from '../../pipeline/types';
11
+ import type { ToolContext } from '../server';
12
+ import { runTool, type ToolResult } from './index';
13
+
14
+ interface SearchInput {
15
+ query: string;
16
+ collection?: string;
17
+ limit?: number;
18
+ minScore?: number;
19
+ lang?: string;
20
+ }
21
+
22
+ /**
23
+ * Enrich results with absPath derived from each result's URI.
24
+ */
25
+ function enrichWithAbsPath(
26
+ results: SearchResult[],
27
+ ctx: ToolContext
28
+ ): SearchResult[] {
29
+ return results.map((r) => {
30
+ const parsed = parseUri(r.uri);
31
+ if (!parsed) {
32
+ return r;
33
+ }
34
+
35
+ const collection = ctx.collections.find(
36
+ (c) => c.name === parsed.collection
37
+ );
38
+ if (!collection) {
39
+ return r;
40
+ }
41
+
42
+ return {
43
+ ...r,
44
+ source: {
45
+ ...r.source,
46
+ absPath: pathJoin(collection.path, r.source.relPath),
47
+ },
48
+ };
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Format search results as text for MCP content.
54
+ */
55
+ function formatSearchResults(data: SearchResults): string {
56
+ if (data.results.length === 0) {
57
+ return `No results found for "${data.meta.query}"`;
58
+ }
59
+
60
+ const lines: string[] = [];
61
+ lines.push(`Found ${data.results.length} results for "${data.meta.query}":`);
62
+ lines.push('');
63
+
64
+ for (const r of data.results) {
65
+ lines.push(`[${r.docid}] ${r.uri} (score: ${r.score.toFixed(3)})`);
66
+ if (r.title) {
67
+ lines.push(` Title: ${r.title}`);
68
+ }
69
+ if (r.snippet) {
70
+ const snippetPreview = r.snippet.slice(0, 200).replace(/\n/g, ' ');
71
+ lines.push(` ${snippetPreview}${r.snippet.length > 200 ? '...' : ''}`);
72
+ }
73
+ lines.push('');
74
+ }
75
+
76
+ return lines.join('\n');
77
+ }
78
+
79
+ /**
80
+ * Handle gno_search tool call.
81
+ */
82
+ export function handleSearch(
83
+ args: SearchInput,
84
+ ctx: ToolContext
85
+ ): Promise<ToolResult> {
86
+ return runTool(
87
+ ctx,
88
+ 'gno_search',
89
+ async () => {
90
+ // Validate collection exists if specified
91
+ if (args.collection) {
92
+ const exists = ctx.collections.some((c) => c.name === args.collection);
93
+ if (!exists) {
94
+ throw new Error(`Collection not found: ${args.collection}`);
95
+ }
96
+ }
97
+
98
+ const result = await searchBm25(ctx.store, args.query, {
99
+ limit: args.limit ?? 5,
100
+ minScore: args.minScore,
101
+ collection: args.collection,
102
+ lang: args.lang,
103
+ });
104
+
105
+ if (!result.ok) {
106
+ throw new Error(result.error.message);
107
+ }
108
+
109
+ // Enrich with absPath
110
+ const enrichedResults = enrichWithAbsPath(result.value.results, ctx);
111
+
112
+ return {
113
+ ...result.value,
114
+ results: enrichedResults,
115
+ };
116
+ },
117
+ formatSearchResults
118
+ );
119
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * MCP gno_status tool - Index status and health.
3
+ *
4
+ * @module src/mcp/tools/status
5
+ */
6
+
7
+ import type { IndexStatus } from '../../store/types';
8
+ import type { ToolContext } from '../server';
9
+ import { runTool, type ToolResult } from './index';
10
+
11
+ type StatusInput = Record<string, never>;
12
+
13
+ /**
14
+ * Format status as text for MCP content.
15
+ */
16
+ function formatStatus(status: IndexStatus): string {
17
+ const lines: string[] = [];
18
+
19
+ lines.push(`Index: ${status.indexName}`);
20
+ lines.push(`Config: ${status.configPath}`);
21
+ lines.push(`Database: ${status.dbPath}`);
22
+ lines.push(`Health: ${status.healthy ? 'OK' : 'DEGRADED'}`);
23
+ lines.push('');
24
+
25
+ if (status.collections.length === 0) {
26
+ lines.push('No collections configured.');
27
+ } else {
28
+ lines.push('Collections:');
29
+ for (const c of status.collections) {
30
+ lines.push(
31
+ ` ${c.name}: ${c.activeDocuments} docs, ${c.totalChunks} chunks` +
32
+ (c.embeddedChunks > 0 ? `, ${c.embeddedChunks} embedded` : '')
33
+ );
34
+ }
35
+ }
36
+
37
+ lines.push('');
38
+ lines.push(
39
+ `Total: ${status.activeDocuments} documents, ${status.totalChunks} chunks`
40
+ );
41
+
42
+ if (status.embeddingBacklog > 0) {
43
+ lines.push(`Embedding backlog: ${status.embeddingBacklog} chunks`);
44
+ }
45
+
46
+ if (status.recentErrors > 0) {
47
+ lines.push(`Recent errors: ${status.recentErrors} (last 24h)`);
48
+ }
49
+
50
+ if (status.lastUpdatedAt) {
51
+ lines.push(`Last updated: ${status.lastUpdatedAt}`);
52
+ }
53
+
54
+ return lines.join('\n');
55
+ }
56
+
57
+ /**
58
+ * Handle gno_status tool call.
59
+ */
60
+ export function handleStatus(
61
+ _args: StatusInput,
62
+ ctx: ToolContext
63
+ ): Promise<ToolResult> {
64
+ return runTool(
65
+ ctx,
66
+ 'gno_status',
67
+ async () => {
68
+ const result = await ctx.store.getStatus();
69
+ if (!result.ok) {
70
+ throw new Error(result.error.message);
71
+ }
72
+
73
+ // Override configPath with actual path from context
74
+ return {
75
+ ...result.value,
76
+ configPath: ctx.actualConfigPath,
77
+ };
78
+ },
79
+ formatStatus
80
+ );
81
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * MCP gno_vsearch tool - Vector/semantic similarity search.
3
+ *
4
+ * @module src/mcp/tools/vsearch
5
+ */
6
+
7
+ import { join as pathJoin } from 'node:path';
8
+ import { parseUri } from '../../app/constants';
9
+ import { LlmAdapter } from '../../llm/nodeLlamaCpp/adapter';
10
+ import { getActivePreset } from '../../llm/registry';
11
+ import type { SearchResult, SearchResults } from '../../pipeline/types';
12
+ import {
13
+ searchVectorWithEmbedding,
14
+ type VectorSearchDeps,
15
+ } from '../../pipeline/vsearch';
16
+ import { createVectorIndexPort } from '../../store/vector';
17
+ import type { ToolContext } from '../server';
18
+ import { runTool, type ToolResult } from './index';
19
+
20
+ interface VsearchInput {
21
+ query: string;
22
+ collection?: string;
23
+ limit?: number;
24
+ minScore?: number;
25
+ lang?: string;
26
+ }
27
+
28
+ /**
29
+ * Enrich results with absPath derived from each result's URI.
30
+ */
31
+ function enrichWithAbsPath(
32
+ results: SearchResult[],
33
+ ctx: ToolContext
34
+ ): SearchResult[] {
35
+ return results.map((r) => {
36
+ const parsed = parseUri(r.uri);
37
+ if (!parsed) {
38
+ return r;
39
+ }
40
+
41
+ const collection = ctx.collections.find(
42
+ (c) => c.name === parsed.collection
43
+ );
44
+ if (!collection) {
45
+ return r;
46
+ }
47
+
48
+ return {
49
+ ...r,
50
+ source: {
51
+ ...r.source,
52
+ absPath: pathJoin(collection.path, r.source.relPath),
53
+ },
54
+ };
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Format search results as text for MCP content.
60
+ */
61
+ function formatSearchResults(data: SearchResults): string {
62
+ if (data.results.length === 0) {
63
+ return `No results found for "${data.meta.query}"`;
64
+ }
65
+
66
+ const lines: string[] = [];
67
+ lines.push(
68
+ `Found ${data.results.length} results for "${data.meta.query}" (vector search):`
69
+ );
70
+ lines.push('');
71
+
72
+ for (const r of data.results) {
73
+ lines.push(`[${r.docid}] ${r.uri} (score: ${r.score.toFixed(3)})`);
74
+ if (r.title) {
75
+ lines.push(` Title: ${r.title}`);
76
+ }
77
+ if (r.snippet) {
78
+ const snippetPreview = r.snippet.slice(0, 200).replace(/\n/g, ' ');
79
+ lines.push(` ${snippetPreview}${r.snippet.length > 200 ? '...' : ''}`);
80
+ }
81
+ lines.push('');
82
+ }
83
+
84
+ return lines.join('\n');
85
+ }
86
+
87
+ /**
88
+ * Handle gno_vsearch tool call.
89
+ */
90
+ export function handleVsearch(
91
+ args: VsearchInput,
92
+ ctx: ToolContext
93
+ ): Promise<ToolResult> {
94
+ return runTool(
95
+ ctx,
96
+ 'gno_vsearch',
97
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: vector search with validation and result formatting
98
+ async () => {
99
+ // Validate collection exists if specified
100
+ if (args.collection) {
101
+ const exists = ctx.collections.some((c) => c.name === args.collection);
102
+ if (!exists) {
103
+ throw new Error(`Collection not found: ${args.collection}`);
104
+ }
105
+ }
106
+
107
+ // Get model from active preset
108
+ const preset = getActivePreset(ctx.config);
109
+ const modelUri = preset.embed;
110
+
111
+ // Create LLM adapter for embeddings
112
+ const llm = new LlmAdapter(ctx.config);
113
+ const embedResult = await llm.createEmbeddingPort(modelUri);
114
+ if (!embedResult.ok) {
115
+ throw new Error(
116
+ `Failed to load embedding model: ${embedResult.error.message}. ` +
117
+ 'Ensure models are downloaded with: gno models pull'
118
+ );
119
+ }
120
+
121
+ const embedPort = embedResult.value;
122
+
123
+ try {
124
+ // Embed query
125
+ const queryEmbedResult = await embedPort.embed(args.query);
126
+ if (!queryEmbedResult.ok) {
127
+ throw new Error(queryEmbedResult.error.message);
128
+ }
129
+ const queryEmbedding = new Float32Array(queryEmbedResult.value);
130
+ const dimensions = queryEmbedding.length;
131
+
132
+ // Create vector index port
133
+ const db = ctx.store.getRawDb();
134
+ const vectorResult = await createVectorIndexPort(db, {
135
+ model: modelUri,
136
+ dimensions,
137
+ });
138
+
139
+ if (!vectorResult.ok) {
140
+ throw new Error(
141
+ `Vector index not available: ${vectorResult.error.message}. ` +
142
+ 'Run: gno embed'
143
+ );
144
+ }
145
+
146
+ const vectorIndex = vectorResult.value;
147
+
148
+ if (!vectorIndex.searchAvailable) {
149
+ const reason = vectorIndex.loadError
150
+ ? `sqlite-vec not loaded: ${vectorIndex.loadError}`
151
+ : 'sqlite-vec not available';
152
+ throw new Error(
153
+ `Vector search unavailable (${reason}). ` +
154
+ 'Ensure sqlite-vec is installed for your platform.'
155
+ );
156
+ }
157
+
158
+ const deps: VectorSearchDeps = {
159
+ store: ctx.store,
160
+ vectorIndex,
161
+ embedPort,
162
+ config: ctx.config,
163
+ };
164
+
165
+ const result = await searchVectorWithEmbedding(
166
+ deps,
167
+ args.query,
168
+ queryEmbedding,
169
+ {
170
+ limit: args.limit ?? 5,
171
+ minScore: args.minScore,
172
+ collection: args.collection,
173
+ }
174
+ );
175
+
176
+ if (!result.ok) {
177
+ throw new Error(result.error.message);
178
+ }
179
+
180
+ // Enrich with absPath
181
+ const enrichedResults = enrichWithAbsPath(result.value.results, ctx);
182
+
183
+ return {
184
+ ...result.value,
185
+ results: enrichedResults,
186
+ meta: {
187
+ ...result.value.meta,
188
+ // Add queryLanguage hint if provided (per spec, lang is a hint for vsearch)
189
+ ...(args.lang ? { queryLanguage: args.lang } : {}),
190
+ },
191
+ };
192
+ } finally {
193
+ await embedPort.dispose();
194
+ }
195
+ },
196
+ formatSearchResults
197
+ );
198
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Chunk lookup optimization.
3
+ * Provides O(1) chunk lookup by (hash, seq) instead of O(n) linear scan.
4
+ *
5
+ * Performance tradeoff: Pays O(k) Map build cost per hash on first access
6
+ * to guarantee O(1) lookups thereafter. For one-off lookups, .find() might
7
+ * be marginally faster, but we prefer consistent O(1) access across
8
+ * pipelines where multiple lookups per hash are common.
9
+ *
10
+ * @module src/pipeline/chunk-lookup
11
+ */
12
+
13
+ import type { ChunkRow } from '../store/types';
14
+
15
+ /**
16
+ * Create O(1) chunk lookup function from batch-fetched chunks.
17
+ * Lazily indexes chunks by seq on first access per hash.
18
+ * First-wins semantics preserves original .find() behavior for duplicate seq.
19
+ *
20
+ * @param chunksMap - Map from getChunksBatch()
21
+ * @returns Lookup function (hash, seq) => ChunkRow | undefined
22
+ */
23
+ export function createChunkLookup(
24
+ chunksMap: Map<string, ChunkRow[]>
25
+ ): (hash: string, seq: number) => ChunkRow | undefined {
26
+ // Lazy cache: hash -> (seq -> chunk)
27
+ const indexCache = new Map<string, Map<number, ChunkRow>>();
28
+
29
+ return (hash: string, seq: number): ChunkRow | undefined => {
30
+ let bySeq = indexCache.get(hash);
31
+ if (!bySeq) {
32
+ const chunks = chunksMap.get(hash) ?? [];
33
+ bySeq = new Map<number, ChunkRow>();
34
+ // First-wins: preserve .find() behavior for degenerate duplicate seq
35
+ for (const ch of chunks) {
36
+ if (!bySeq.has(ch.seq)) {
37
+ bySeq.set(ch.seq, ch);
38
+ }
39
+ }
40
+ indexCache.set(hash, bySeq);
41
+ }
42
+ return bySeq.get(seq);
43
+ };
44
+ }