@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,545 @@
1
+ /**
2
+ * gno ask command implementation.
3
+ * Human-friendly query with citations and optional grounded answer.
4
+ *
5
+ * @module src/cli/commands/ask
6
+ */
7
+
8
+ import { LlmAdapter } from '../../llm/nodeLlamaCpp/adapter';
9
+ import { getActivePreset } from '../../llm/registry';
10
+ import type {
11
+ EmbeddingPort,
12
+ GenerationPort,
13
+ RerankPort,
14
+ } from '../../llm/types';
15
+ import { type HybridSearchDeps, searchHybrid } from '../../pipeline/hybrid';
16
+ import type {
17
+ AskOptions,
18
+ AskResult,
19
+ Citation,
20
+ SearchResult,
21
+ } from '../../pipeline/types';
22
+ import {
23
+ createVectorIndexPort,
24
+ type VectorIndexPort,
25
+ } from '../../store/vector';
26
+ import { initStore } from './shared';
27
+
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+ // Types
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ export type AskCommandOptions = AskOptions & {
33
+ /** Override config path */
34
+ configPath?: string;
35
+ /** Override embedding model */
36
+ embedModel?: string;
37
+ /** Override generation model */
38
+ genModel?: string;
39
+ /** Override rerank model */
40
+ rerankModel?: string;
41
+ /** Output as JSON */
42
+ json?: boolean;
43
+ /** Output as Markdown */
44
+ md?: boolean;
45
+ /** Show all retrieved sources (not just cited) */
46
+ showSources?: boolean;
47
+ };
48
+
49
+ export type AskCommandResult =
50
+ | { success: true; data: AskResult }
51
+ | { success: false; error: string };
52
+
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+ // Grounded Answer Generation
55
+ // ─────────────────────────────────────────────────────────────────────────────
56
+
57
+ const ANSWER_PROMPT = `You are answering a question using ONLY the provided context blocks.
58
+
59
+ Rules you MUST follow:
60
+ 1) Use ONLY facts stated in the context blocks. Do NOT use outside knowledge.
61
+ 2) Every factual statement must include an inline citation like [1] or [2] referring to a context block.
62
+ 3) If the context does not contain enough information to answer, reply EXACTLY:
63
+ "I don't have enough information in the provided sources to answer this question."
64
+ 4) Do not cite sources you did not use. Do not invent citation numbers.
65
+
66
+ Question: {query}
67
+
68
+ Context blocks:
69
+ {context}
70
+
71
+ Write a concise answer (1-3 paragraphs).`;
72
+
73
+ /** Abstention message when LLM cannot ground answer */
74
+ const ABSTENTION_MESSAGE =
75
+ "I don't have enough information in the provided sources to answer this question.";
76
+
77
+ // Max characters per snippet to avoid blowing up prompt size
78
+ const MAX_SNIPPET_CHARS = 1500;
79
+ // Max number of sources to include in context
80
+ const MAX_CONTEXT_SOURCES = 5;
81
+
82
+ /**
83
+ * Extract VALID citation numbers from answer text.
84
+ * Only returns numbers in range [1, maxCitation].
85
+ * @param answer Answer text to parse
86
+ * @param maxCitation Maximum valid citation number
87
+ * @returns Sorted unique valid citation numbers (1-indexed)
88
+ */
89
+ function extractValidCitationNumbers(
90
+ answer: string,
91
+ maxCitation: number
92
+ ): number[] {
93
+ const nums = new Set<number>();
94
+ // Use fresh regex to avoid lastIndex issues
95
+ const re = /\[(\d+)\]/g;
96
+ const matches = answer.matchAll(re);
97
+ for (const match of matches) {
98
+ const n = Number(match[1]);
99
+ // Only accept valid citation numbers in range [1, maxCitation]
100
+ if (Number.isInteger(n) && n >= 1 && n <= maxCitation) {
101
+ nums.add(n);
102
+ }
103
+ }
104
+ return [...nums].sort((a, b) => a - b);
105
+ }
106
+
107
+ /**
108
+ * Filter citations to only those actually referenced in the answer.
109
+ * @param citations All citations provided to LLM
110
+ * @param validUsedNumbers Valid 1-indexed citation numbers from answer
111
+ */
112
+ function filterCitationsByUse(
113
+ citations: Citation[],
114
+ validUsedNumbers: number[]
115
+ ): Citation[] {
116
+ const usedSet = new Set(validUsedNumbers);
117
+ return citations.filter((_, idx) => usedSet.has(idx + 1));
118
+ }
119
+
120
+ /**
121
+ * Renumber citations in answer text to match filtered citations.
122
+ * E.g., if answer uses [2] and [5], renumber to [1] and [2].
123
+ * Invalid citations (not in validUsedNumbers) are removed.
124
+ */
125
+ function renumberAnswerCitations(
126
+ answer: string,
127
+ validUsedNumbers: number[]
128
+ ): string {
129
+ // Build mapping: old number -> new number (1-indexed)
130
+ const mapping = new Map<number, number>();
131
+ for (let i = 0; i < validUsedNumbers.length; i++) {
132
+ const oldNum = validUsedNumbers[i];
133
+ if (oldNum !== undefined) {
134
+ mapping.set(oldNum, i + 1);
135
+ }
136
+ }
137
+
138
+ // Use fresh regex to avoid lastIndex issues
139
+ const re = /\[(\d+)\]/g;
140
+ // Replace valid [n] with renumbered [m], remove invalid citations
141
+ const replaced = answer.replace(re, (_match, numStr: string) => {
142
+ const oldNum = Number(numStr);
143
+ const newNum = mapping.get(oldNum);
144
+ // If not in mapping, remove the citation entirely
145
+ return newNum !== undefined ? `[${newNum}]` : '';
146
+ });
147
+
148
+ // Clean up whitespace artifacts from removed citations
149
+ // e.g., "See [99] for" → "See for" → "See for"
150
+ return replaced.replace(/ {2,}/g, ' ').trim();
151
+ }
152
+
153
+ async function generateGroundedAnswer(
154
+ genPort: GenerationPort,
155
+ query: string,
156
+ results: SearchResult[],
157
+ maxTokens: number
158
+ ): Promise<{ answer: string; citations: Citation[] } | null> {
159
+ // Build context from top results with bounded snippet sizes
160
+ const contextParts: string[] = [];
161
+ const citations: Citation[] = [];
162
+
163
+ // Track citation index separately to ensure it matches context blocks exactly
164
+ let citationIndex = 0;
165
+
166
+ for (const r of results.slice(0, MAX_CONTEXT_SOURCES)) {
167
+ // Skip results with empty snippets
168
+ if (!r.snippet || r.snippet.trim().length === 0) {
169
+ continue;
170
+ }
171
+
172
+ // Cap snippet length to avoid prompt blowup
173
+ const snippet =
174
+ r.snippet.length > MAX_SNIPPET_CHARS
175
+ ? `${r.snippet.slice(0, MAX_SNIPPET_CHARS)}...`
176
+ : r.snippet;
177
+
178
+ citationIndex += 1;
179
+ contextParts.push(`[${citationIndex}] ${snippet}`);
180
+ citations.push({
181
+ docid: r.docid,
182
+ uri: r.uri,
183
+ startLine: r.snippetRange?.startLine,
184
+ endLine: r.snippetRange?.endLine,
185
+ });
186
+ }
187
+
188
+ // If no valid context, can't generate answer
189
+ if (contextParts.length === 0) {
190
+ return null;
191
+ }
192
+
193
+ const prompt = ANSWER_PROMPT.replace('{query}', query).replace(
194
+ '{context}',
195
+ contextParts.join('\n\n')
196
+ );
197
+
198
+ const result = await genPort.generate(prompt, {
199
+ temperature: 0,
200
+ maxTokens,
201
+ });
202
+
203
+ if (!result.ok) {
204
+ return null;
205
+ }
206
+
207
+ return { answer: result.value, citations };
208
+ }
209
+
210
+ // ─────────────────────────────────────────────────────────────────────────────
211
+ // Command Implementation
212
+ // ─────────────────────────────────────────────────────────────────────────────
213
+
214
+ /**
215
+ * Execute gno ask command.
216
+ */
217
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: CLI orchestration with multiple output formats
218
+ export async function ask(
219
+ query: string,
220
+ options: AskCommandOptions = {}
221
+ ): Promise<AskCommandResult> {
222
+ const limit = options.limit ?? 5;
223
+
224
+ const initResult = await initStore({
225
+ configPath: options.configPath,
226
+ collection: options.collection,
227
+ });
228
+
229
+ if (!initResult.ok) {
230
+ return { success: false, error: initResult.error };
231
+ }
232
+
233
+ const { store, config } = initResult;
234
+
235
+ let embedPort: EmbeddingPort | null = null;
236
+ let genPort: GenerationPort | null = null;
237
+ let rerankPort: RerankPort | null = null;
238
+
239
+ try {
240
+ const preset = getActivePreset(config);
241
+ const llm = new LlmAdapter(config);
242
+
243
+ // Create embedding port
244
+ const embedUri = options.embedModel ?? preset.embed;
245
+ const embedResult = await llm.createEmbeddingPort(embedUri);
246
+ if (embedResult.ok) {
247
+ embedPort = embedResult.value;
248
+ }
249
+
250
+ // Create generation port (for expansion and answer)
251
+ const genUri = options.genModel ?? preset.gen;
252
+ const genResult = await llm.createGenerationPort(genUri);
253
+ if (genResult.ok) {
254
+ genPort = genResult.value;
255
+ }
256
+
257
+ // Create rerank port
258
+ const rerankUri = options.rerankModel ?? preset.rerank;
259
+ const rerankResult = await llm.createRerankPort(rerankUri);
260
+ if (rerankResult.ok) {
261
+ rerankPort = rerankResult.value;
262
+ }
263
+
264
+ // Create vector index
265
+ let vectorIndex: VectorIndexPort | null = null;
266
+ if (embedPort) {
267
+ const embedInitResult = await embedPort.init();
268
+ if (embedInitResult.ok) {
269
+ const dimensions = embedPort.dimensions();
270
+ const db = store.getRawDb();
271
+ const vectorResult = await createVectorIndexPort(db, {
272
+ model: embedUri,
273
+ dimensions,
274
+ });
275
+ if (vectorResult.ok) {
276
+ vectorIndex = vectorResult.value;
277
+ }
278
+ }
279
+ }
280
+
281
+ const deps: HybridSearchDeps = {
282
+ store,
283
+ config,
284
+ vectorIndex,
285
+ embedPort,
286
+ genPort,
287
+ rerankPort,
288
+ };
289
+
290
+ // Check if answer generation is explicitly requested
291
+ const answerRequested = options.answer && !options.noAnswer;
292
+
293
+ // Fail early if --answer is requested but no generation model available
294
+ if (answerRequested && genPort === null) {
295
+ return {
296
+ success: false,
297
+ error:
298
+ 'Answer generation requested but no generation model available. ' +
299
+ 'Run `gno models pull --gen` to download a model, or configure a preset.',
300
+ };
301
+ }
302
+
303
+ // Run hybrid search
304
+ const searchResult = await searchHybrid(deps, query, {
305
+ limit,
306
+ collection: options.collection,
307
+ lang: options.lang,
308
+ });
309
+
310
+ if (!searchResult.ok) {
311
+ return { success: false, error: searchResult.error.message };
312
+ }
313
+
314
+ const results = searchResult.value.results;
315
+
316
+ // Generate grounded answer if requested
317
+ let answer: string | undefined;
318
+ let citations: Citation[] | undefined;
319
+ let answerGenerated = false;
320
+
321
+ // Only generate answer if:
322
+ // 1. --answer was explicitly requested (not just default behavior)
323
+ // 2. --no-answer was not set
324
+ // 3. We have results to ground on (no point generating from nothing)
325
+ const shouldGenerateAnswer =
326
+ answerRequested && genPort !== null && results.length > 0;
327
+
328
+ if (shouldGenerateAnswer && genPort) {
329
+ const maxTokens = options.maxAnswerTokens ?? 512;
330
+ const answerResult = await generateGroundedAnswer(
331
+ genPort,
332
+ query,
333
+ results,
334
+ maxTokens
335
+ );
336
+
337
+ // Fail loudly if generation was requested but failed
338
+ if (!answerResult) {
339
+ return {
340
+ success: false,
341
+ error:
342
+ 'Answer generation failed. The generation model may have encountered an error.',
343
+ };
344
+ }
345
+
346
+ // Extract only VALID citation numbers (in range 1..citations.length)
347
+ const maxCitation = answerResult.citations.length;
348
+ const validUsedNums = extractValidCitationNumbers(
349
+ answerResult.answer,
350
+ maxCitation
351
+ );
352
+ const filteredCitations = filterCitationsByUse(
353
+ answerResult.citations,
354
+ validUsedNums
355
+ );
356
+
357
+ // Abstention guard: if no valid citations, LLM didn't ground the answer
358
+ if (validUsedNums.length === 0 || filteredCitations.length === 0) {
359
+ answer = ABSTENTION_MESSAGE;
360
+ citations = [];
361
+ } else {
362
+ // Renumber citations in answer to match filtered list (e.g., [2],[5] -> [1],[2])
363
+ // Invalid citations are removed from the answer text
364
+ answer = renumberAnswerCitations(answerResult.answer, validUsedNums);
365
+ citations = filteredCitations;
366
+ }
367
+ answerGenerated = true;
368
+ }
369
+
370
+ const askResult: AskResult = {
371
+ query,
372
+ mode: searchResult.value.meta.vectorsUsed ? 'hybrid' : 'bm25_only',
373
+ queryLanguage: searchResult.value.meta.queryLanguage ?? 'und',
374
+ answer,
375
+ citations,
376
+ results,
377
+ meta: {
378
+ expanded: searchResult.value.meta.expanded ?? false,
379
+ reranked: searchResult.value.meta.reranked ?? false,
380
+ vectorsUsed: searchResult.value.meta.vectorsUsed ?? false,
381
+ answerGenerated,
382
+ totalResults: results.length,
383
+ },
384
+ };
385
+
386
+ return { success: true, data: askResult };
387
+ } finally {
388
+ if (embedPort) {
389
+ await embedPort.dispose();
390
+ }
391
+ if (genPort) {
392
+ await genPort.dispose();
393
+ }
394
+ if (rerankPort) {
395
+ await rerankPort.dispose();
396
+ }
397
+ await store.close();
398
+ }
399
+ }
400
+
401
+ // ─────────────────────────────────────────────────────────────────────────────
402
+ // Formatters
403
+ // ─────────────────────────────────────────────────────────────────────────────
404
+
405
+ interface FormatOptions {
406
+ showSources?: boolean;
407
+ }
408
+
409
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: terminal formatting with conditional sections
410
+ function formatTerminal(data: AskResult, opts: FormatOptions = {}): string {
411
+ const lines: string[] = [];
412
+ const hasAnswer = Boolean(data.answer);
413
+
414
+ // Show answer if present
415
+ if (data.answer) {
416
+ lines.push('Answer:');
417
+ lines.push(data.answer);
418
+ lines.push('');
419
+ }
420
+
421
+ // Show cited sources (only sources actually referenced in answer)
422
+ if (data.citations && data.citations.length > 0) {
423
+ lines.push('Cited Sources:');
424
+ for (let i = 0; i < data.citations.length; i++) {
425
+ const c = data.citations[i];
426
+ if (c) {
427
+ lines.push(` [${i + 1}] ${c.uri}`);
428
+ }
429
+ }
430
+ lines.push('');
431
+ }
432
+
433
+ // Show all retrieved sources if:
434
+ // - No answer was generated (retrieval-only mode)
435
+ // - User explicitly requested with --show-sources
436
+ const showAllSources = !hasAnswer || opts.showSources;
437
+ if (showAllSources && data.results.length > 0) {
438
+ lines.push(hasAnswer ? 'All Retrieved Sources:' : 'Sources:');
439
+ for (const r of data.results) {
440
+ lines.push(` [${r.docid}] ${r.uri}`);
441
+ if (r.title) {
442
+ lines.push(` ${r.title}`);
443
+ }
444
+ }
445
+ } else if (hasAnswer && data.results.length > 0) {
446
+ // Hint about --show-sources when we have more sources
447
+ const citedCount = data.citations?.length ?? 0;
448
+ if (data.results.length > citedCount) {
449
+ lines.push(
450
+ `(${data.results.length} sources retrieved, use --show-sources to list all)`
451
+ );
452
+ }
453
+ }
454
+
455
+ if (!data.answer && data.results.length === 0) {
456
+ lines.push('No relevant sources found.');
457
+ }
458
+
459
+ return lines.join('\n');
460
+ }
461
+
462
+ function formatMarkdown(data: AskResult, opts: FormatOptions = {}): string {
463
+ const lines: string[] = [];
464
+ const hasAnswer = Boolean(data.answer);
465
+
466
+ lines.push(`# Question: ${data.query}`);
467
+ lines.push('');
468
+
469
+ if (data.answer) {
470
+ lines.push('## Answer');
471
+ lines.push('');
472
+ lines.push(data.answer);
473
+ lines.push('');
474
+ }
475
+
476
+ // Show cited sources (only sources actually referenced in answer)
477
+ if (data.citations && data.citations.length > 0) {
478
+ lines.push('## Cited Sources');
479
+ lines.push('');
480
+ for (let i = 0; i < data.citations.length; i++) {
481
+ const c = data.citations[i];
482
+ if (c) {
483
+ lines.push(`**[${i + 1}]** \`${c.uri}\``);
484
+ }
485
+ }
486
+ lines.push('');
487
+ }
488
+
489
+ // Show all retrieved sources if no answer or --show-sources
490
+ const showAllSources = !hasAnswer || opts.showSources;
491
+ if (showAllSources) {
492
+ lines.push(hasAnswer ? '## All Retrieved Sources' : '## Sources');
493
+ lines.push('');
494
+
495
+ for (let i = 0; i < data.results.length; i++) {
496
+ const r = data.results[i];
497
+ if (!r) {
498
+ continue;
499
+ }
500
+ lines.push(`${i + 1}. **${r.title || r.source.relPath}**`);
501
+ lines.push(` - URI: \`${r.uri}\``);
502
+ lines.push(` - Score: ${r.score.toFixed(2)}`);
503
+ }
504
+
505
+ if (data.results.length === 0) {
506
+ lines.push('*No relevant sources found.*');
507
+ }
508
+ }
509
+
510
+ lines.push('');
511
+ lines.push('---');
512
+ lines.push(
513
+ `*Mode: ${data.mode} | Expanded: ${data.meta.expanded} | Reranked: ${data.meta.reranked}*`
514
+ );
515
+
516
+ return lines.join('\n');
517
+ }
518
+
519
+ /**
520
+ * Format ask result for output.
521
+ */
522
+ export function formatAsk(
523
+ result: AskCommandResult,
524
+ options: AskCommandOptions
525
+ ): string {
526
+ if (!result.success) {
527
+ return options.json
528
+ ? JSON.stringify({
529
+ error: { code: 'ASK_FAILED', message: result.error },
530
+ })
531
+ : `Error: ${result.error}`;
532
+ }
533
+
534
+ const formatOpts: FormatOptions = { showSources: options.showSources };
535
+
536
+ if (options.json) {
537
+ return JSON.stringify(result.data, null, 2);
538
+ }
539
+
540
+ if (options.md) {
541
+ return formatMarkdown(result.data, formatOpts);
542
+ }
543
+
544
+ return formatTerminal(result.data, formatOpts);
545
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * gno cleanup command implementation.
3
+ * Remove orphaned content, chunks, vectors not referenced by active documents.
4
+ *
5
+ * @module src/cli/commands/cleanup
6
+ */
7
+
8
+ import { getIndexDbPath } from '../../app/constants';
9
+ import { isInitialized, loadConfig } from '../../config';
10
+ import { SqliteAdapter } from '../../store/sqlite/adapter';
11
+ import type { CleanupStats } from '../../store/types';
12
+
13
+ /**
14
+ * Options for cleanup command.
15
+ */
16
+ export interface CleanupOptions {
17
+ /** Override config path */
18
+ configPath?: string;
19
+ }
20
+
21
+ /**
22
+ * Result of cleanup command.
23
+ */
24
+ export type CleanupResult =
25
+ | { success: true; stats: CleanupStats }
26
+ | { success: false; error: string };
27
+
28
+ /**
29
+ * Execute gno cleanup command.
30
+ */
31
+ export async function cleanup(
32
+ options: CleanupOptions = {}
33
+ ): Promise<CleanupResult> {
34
+ // Check if initialized
35
+ const initialized = await isInitialized(options.configPath);
36
+ if (!initialized) {
37
+ return { success: false, error: 'GNO not initialized. Run: gno init' };
38
+ }
39
+
40
+ // Load config
41
+ const configResult = await loadConfig(options.configPath);
42
+ if (!configResult.ok) {
43
+ return { success: false, error: configResult.error.message };
44
+ }
45
+ const config = configResult.value;
46
+
47
+ // Open database
48
+ const store = new SqliteAdapter();
49
+ const dbPath = getIndexDbPath();
50
+
51
+ const openResult = await store.open(dbPath, config.ftsTokenizer);
52
+ if (!openResult.ok) {
53
+ return { success: false, error: openResult.error.message };
54
+ }
55
+
56
+ try {
57
+ const cleanupResult = await store.cleanupOrphans();
58
+ if (!cleanupResult.ok) {
59
+ return { success: false, error: cleanupResult.error.message };
60
+ }
61
+
62
+ return { success: true, stats: cleanupResult.value };
63
+ } finally {
64
+ await store.close();
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Format cleanup result for output.
70
+ */
71
+ export function formatCleanup(result: CleanupResult): string {
72
+ if (!result.success) {
73
+ return `Error: ${result.error}`;
74
+ }
75
+
76
+ const { stats } = result;
77
+ const total =
78
+ stats.orphanedContent +
79
+ stats.orphanedChunks +
80
+ stats.orphanedVectors +
81
+ stats.expiredCache;
82
+
83
+ if (total === 0) {
84
+ return 'No orphans found. Index is clean.';
85
+ }
86
+
87
+ const lines: string[] = ['Cleanup complete:'];
88
+
89
+ if (stats.orphanedContent > 0) {
90
+ lines.push(` Orphaned content: ${stats.orphanedContent}`);
91
+ }
92
+ if (stats.orphanedChunks > 0) {
93
+ lines.push(` Orphaned chunks: ${stats.orphanedChunks}`);
94
+ }
95
+ if (stats.orphanedVectors > 0) {
96
+ lines.push(` Orphaned vectors: ${stats.orphanedVectors}`);
97
+ }
98
+ if (stats.expiredCache > 0) {
99
+ lines.push(` Expired cache: ${stats.expiredCache}`);
100
+ }
101
+
102
+ lines.push(`Total removed: ${total}`);
103
+
104
+ return lines.join('\n');
105
+ }