@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.
- package/README.md +256 -0
- package/assets/skill/SKILL.md +112 -0
- package/assets/skill/cli-reference.md +327 -0
- package/assets/skill/examples.md +234 -0
- package/assets/skill/mcp-reference.md +159 -0
- package/package.json +90 -0
- package/src/app/constants.ts +313 -0
- package/src/cli/colors.ts +65 -0
- package/src/cli/commands/ask.ts +545 -0
- package/src/cli/commands/cleanup.ts +105 -0
- package/src/cli/commands/collection/add.ts +120 -0
- package/src/cli/commands/collection/index.ts +10 -0
- package/src/cli/commands/collection/list.ts +108 -0
- package/src/cli/commands/collection/remove.ts +64 -0
- package/src/cli/commands/collection/rename.ts +95 -0
- package/src/cli/commands/context/add.ts +67 -0
- package/src/cli/commands/context/check.ts +153 -0
- package/src/cli/commands/context/index.ts +10 -0
- package/src/cli/commands/context/list.ts +109 -0
- package/src/cli/commands/context/rm.ts +52 -0
- package/src/cli/commands/doctor.ts +393 -0
- package/src/cli/commands/embed.ts +462 -0
- package/src/cli/commands/get.ts +356 -0
- package/src/cli/commands/index-cmd.ts +119 -0
- package/src/cli/commands/index.ts +102 -0
- package/src/cli/commands/init.ts +328 -0
- package/src/cli/commands/ls.ts +217 -0
- package/src/cli/commands/mcp/config.ts +300 -0
- package/src/cli/commands/mcp/index.ts +24 -0
- package/src/cli/commands/mcp/install.ts +203 -0
- package/src/cli/commands/mcp/paths.ts +470 -0
- package/src/cli/commands/mcp/status.ts +222 -0
- package/src/cli/commands/mcp/uninstall.ts +158 -0
- package/src/cli/commands/mcp.ts +20 -0
- package/src/cli/commands/models/clear.ts +103 -0
- package/src/cli/commands/models/index.ts +32 -0
- package/src/cli/commands/models/list.ts +214 -0
- package/src/cli/commands/models/path.ts +51 -0
- package/src/cli/commands/models/pull.ts +199 -0
- package/src/cli/commands/models/use.ts +85 -0
- package/src/cli/commands/multi-get.ts +400 -0
- package/src/cli/commands/query.ts +220 -0
- package/src/cli/commands/ref-parser.ts +108 -0
- package/src/cli/commands/reset.ts +191 -0
- package/src/cli/commands/search.ts +136 -0
- package/src/cli/commands/shared.ts +156 -0
- package/src/cli/commands/skill/index.ts +19 -0
- package/src/cli/commands/skill/install.ts +197 -0
- package/src/cli/commands/skill/paths-cmd.ts +81 -0
- package/src/cli/commands/skill/paths.ts +191 -0
- package/src/cli/commands/skill/show.ts +73 -0
- package/src/cli/commands/skill/uninstall.ts +141 -0
- package/src/cli/commands/status.ts +205 -0
- package/src/cli/commands/update.ts +68 -0
- package/src/cli/commands/vsearch.ts +188 -0
- package/src/cli/context.ts +64 -0
- package/src/cli/errors.ts +64 -0
- package/src/cli/format/search-results.ts +211 -0
- package/src/cli/options.ts +183 -0
- package/src/cli/program.ts +1330 -0
- package/src/cli/run.ts +213 -0
- package/src/cli/ui.ts +92 -0
- package/src/config/defaults.ts +20 -0
- package/src/config/index.ts +55 -0
- package/src/config/loader.ts +161 -0
- package/src/config/paths.ts +87 -0
- package/src/config/saver.ts +153 -0
- package/src/config/types.ts +280 -0
- package/src/converters/adapters/markitdownTs/adapter.ts +140 -0
- package/src/converters/adapters/officeparser/adapter.ts +126 -0
- package/src/converters/canonicalize.ts +89 -0
- package/src/converters/errors.ts +218 -0
- package/src/converters/index.ts +51 -0
- package/src/converters/mime.ts +163 -0
- package/src/converters/native/markdown.ts +115 -0
- package/src/converters/native/plaintext.ts +56 -0
- package/src/converters/path.ts +48 -0
- package/src/converters/pipeline.ts +159 -0
- package/src/converters/registry.ts +74 -0
- package/src/converters/types.ts +123 -0
- package/src/converters/versions.ts +24 -0
- package/src/index.ts +27 -0
- package/src/ingestion/chunker.ts +238 -0
- package/src/ingestion/index.ts +32 -0
- package/src/ingestion/language.ts +276 -0
- package/src/ingestion/sync.ts +671 -0
- package/src/ingestion/types.ts +219 -0
- package/src/ingestion/walker.ts +235 -0
- package/src/llm/cache.ts +467 -0
- package/src/llm/errors.ts +191 -0
- package/src/llm/index.ts +58 -0
- package/src/llm/nodeLlamaCpp/adapter.ts +133 -0
- package/src/llm/nodeLlamaCpp/embedding.ts +165 -0
- package/src/llm/nodeLlamaCpp/generation.ts +88 -0
- package/src/llm/nodeLlamaCpp/lifecycle.ts +317 -0
- package/src/llm/nodeLlamaCpp/rerank.ts +94 -0
- package/src/llm/registry.ts +86 -0
- package/src/llm/types.ts +129 -0
- package/src/mcp/resources/index.ts +151 -0
- package/src/mcp/server.ts +229 -0
- package/src/mcp/tools/get.ts +220 -0
- package/src/mcp/tools/index.ts +160 -0
- package/src/mcp/tools/multi-get.ts +263 -0
- package/src/mcp/tools/query.ts +226 -0
- package/src/mcp/tools/search.ts +119 -0
- package/src/mcp/tools/status.ts +81 -0
- package/src/mcp/tools/vsearch.ts +198 -0
- package/src/pipeline/chunk-lookup.ts +44 -0
- package/src/pipeline/expansion.ts +256 -0
- package/src/pipeline/explain.ts +115 -0
- package/src/pipeline/fusion.ts +185 -0
- package/src/pipeline/hybrid.ts +535 -0
- package/src/pipeline/index.ts +64 -0
- package/src/pipeline/query-language.ts +118 -0
- package/src/pipeline/rerank.ts +223 -0
- package/src/pipeline/search.ts +261 -0
- package/src/pipeline/types.ts +328 -0
- package/src/pipeline/vsearch.ts +348 -0
- package/src/store/index.ts +41 -0
- package/src/store/migrations/001-initial.ts +196 -0
- package/src/store/migrations/index.ts +20 -0
- package/src/store/migrations/runner.ts +187 -0
- package/src/store/sqlite/adapter.ts +1242 -0
- package/src/store/sqlite/index.ts +7 -0
- package/src/store/sqlite/setup.ts +129 -0
- package/src/store/sqlite/types.ts +28 -0
- package/src/store/types.ts +506 -0
- package/src/store/vector/index.ts +13 -0
- package/src/store/vector/sqlite-vec.ts +373 -0
- package/src/store/vector/stats.ts +152 -0
- 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
|
+
}
|