@gmickel/gno 1.19.0 → 1.20.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 (45) hide show
  1. package/README.md +12 -7
  2. package/assets/skill/SKILL.md +27 -12
  3. package/assets/skill/mcp-reference.md +7 -2
  4. package/assets/skill/recipes/citation-and-provenance.md +32 -9
  5. package/package.json +1 -1
  6. package/spec/cli.md +42 -17
  7. package/spec/evals-agentic.md +87 -5
  8. package/spec/mcp.md +53 -3
  9. package/spec/output-schemas/ask.schema.json +198 -0
  10. package/spec/output-schemas/claim-verification.schema.json +291 -0
  11. package/spec/output-schemas/context-capsule-v1.schema.json +36 -1
  12. package/src/app/context-runtime-contract.ts +10 -5
  13. package/src/app/context-runtime-input.ts +29 -1
  14. package/src/app/context-runtime-types.ts +4 -0
  15. package/src/app/context-runtime.ts +5 -1
  16. package/src/app/context-surface.ts +4 -0
  17. package/src/app/verified-ask.ts +291 -0
  18. package/src/cli/commands/ask-format.ts +255 -0
  19. package/src/cli/commands/ask.ts +40 -149
  20. package/src/cli/program.ts +32 -1
  21. package/src/core/context-budget.ts +6 -0
  22. package/src/core/context-capsule-retrieval-schema.ts +4 -0
  23. package/src/core/context-capsule-schema.ts +17 -0
  24. package/src/core/context-capsule-validation.ts +3 -2
  25. package/src/core/context-capsule.ts +18 -0
  26. package/src/core/context-compiler.ts +33 -21
  27. package/src/core/context-evidence.ts +6 -0
  28. package/src/core/retrieval-trace-evidence-origin.ts +3 -0
  29. package/src/core/retrieval-trace-session.ts +15 -2
  30. package/src/llm/errors.ts +10 -1
  31. package/src/llm/httpGeneration.ts +11 -1
  32. package/src/llm/nodeLlamaCpp/generation.ts +54 -10
  33. package/src/llm/types.ts +6 -0
  34. package/src/mcp/tools/ask.ts +228 -0
  35. package/src/mcp/tools/context.ts +28 -7
  36. package/src/mcp/tools/index.ts +9 -0
  37. package/src/pipeline/claim-verification-schema.ts +235 -0
  38. package/src/pipeline/claim-verification.ts +487 -0
  39. package/src/pipeline/claim-verifier.ts +474 -0
  40. package/src/pipeline/types.ts +25 -0
  41. package/src/sdk/client.ts +35 -2
  42. package/src/serve/public/components/AskVerificationPanel.tsx +189 -0
  43. package/src/serve/public/globals.built.css +1 -1
  44. package/src/serve/public/pages/Ask.tsx +42 -4
  45. package/src/serve/routes/api.ts +149 -3
@@ -22,9 +22,13 @@ export interface ContextCapsuleBuildInput {
22
22
  categories?: string[];
23
23
  author?: string;
24
24
  lang?: string;
25
+ intent?: string;
26
+ exclude?: string[];
27
+ minScore?: number;
25
28
  since?: string;
26
29
  until?: string;
27
30
  graph?: boolean;
31
+ noRerank?: boolean;
28
32
  limit?: number;
29
33
  candidateLimit?: number;
30
34
  budgetTokens: number;
@@ -48,7 +48,7 @@ export const buildContextCapsule = async (
48
48
  now,
49
49
  deps.config.collections.map((collection) => collection.name)
50
50
  );
51
- const noRerank = normalized.depthPolicy === "fast";
51
+ const noRerank = normalized.depthPolicy === "fast" || normalized.noRerank;
52
52
  const plan = await compileContextEvidence<ContextCapsuleV1>(
53
53
  {
54
54
  goal: normalized.goal,
@@ -62,9 +62,13 @@ export const buildContextCapsule = async (
62
62
  categories: normalized.categories,
63
63
  author: normalized.author ?? undefined,
64
64
  lang: normalized.lang ?? undefined,
65
+ intent: normalized.intent ?? undefined,
66
+ exclude: normalized.exclude,
67
+ minScore: normalized.minScore ?? undefined,
65
68
  since: normalized.since,
66
69
  until: normalized.until,
67
70
  graph: normalized.graph,
71
+ noRerank: normalized.noRerank,
68
72
  limit: normalized.limit,
69
73
  candidateLimit: normalized.candidateLimit,
70
74
  temporalNow: now,
@@ -30,9 +30,13 @@ export const contextBuildSurfaceSchema = z
30
30
  categories: stringList.optional(),
31
31
  author: z.string().optional(),
32
32
  lang: z.string().optional(),
33
+ intent: z.string().optional(),
34
+ exclude: stringList.optional(),
35
+ minScore: z.number().min(0).max(1).optional(),
33
36
  since: z.string().optional(),
34
37
  until: z.string().optional(),
35
38
  graph: z.boolean().optional(),
39
+ noRerank: z.boolean().optional(),
36
40
  limit: positiveInteger.optional(),
37
41
  candidateLimit: positiveInteger.optional(),
38
42
  budgetTokens: positiveInteger,
@@ -0,0 +1,291 @@
1
+ /** Shared closed-evidence Ask synthesis boundary. */
2
+
3
+ import type {
4
+ ContextCapsuleV1,
5
+ ContextCapsuleVerification,
6
+ } from "../core/context-capsule";
7
+ import type { RetrievalTraceSession } from "../core/retrieval-trace-session";
8
+ import type { GenerationPort } from "../llm/types";
9
+ import type { AskOptions, AskResult, Citation } from "../pipeline/types";
10
+ import type { ContextCapsuleRuntimeDeps } from "./context-runtime";
11
+
12
+ import { buildAnswerPrompt } from "../pipeline/answer-prompt";
13
+ import { CLAIM_ABSTENTION_TEXT } from "../pipeline/claim-verification";
14
+ import { verifyClaimsSemantically } from "../pipeline/claim-verifier";
15
+ import { attachCitationTraceMetadata } from "../pipeline/trace-metadata";
16
+ import { CITATION_TRACE_METADATA } from "../pipeline/types";
17
+ import {
18
+ buildContextCapsule,
19
+ verifyContextCapsuleRuntime,
20
+ } from "./context-runtime";
21
+ import { contextRuntimeConfigFingerprint } from "./context-runtime-contract";
22
+
23
+ const DEFAULT_CONTEXT_BUDGET_TOKENS = 12_000;
24
+ const DEFAULT_MAX_ANSWER_TOKENS = 512;
25
+ const DEFAULT_VERIFIED_ASK_LIMIT = 5;
26
+ const NUMERIC_CITATION_PATTERN = /\[(\d+)\]/g;
27
+
28
+ export interface VerifiedAskDeps extends ContextCapsuleRuntimeDeps {
29
+ genPort: GenerationPort;
30
+ traceSession?: RetrievalTraceSession;
31
+ }
32
+
33
+ const generationSources = (capsule: ContextCapsuleV1) => {
34
+ const guidanceById = new Map(
35
+ capsule.guidance.configuredContexts.map((item) => [
36
+ item.contextId,
37
+ item.text,
38
+ ])
39
+ );
40
+ return capsule.evidence.map((evidence, index) => ({
41
+ index: index + 1,
42
+ docid: evidence.docid,
43
+ uri: evidence.uri,
44
+ content: evidence.text,
45
+ guidance: evidence.contextIds
46
+ .flatMap((contextId) => {
47
+ const text = guidanceById.get(contextId);
48
+ return text ? [text] : [];
49
+ })
50
+ .join("\n"),
51
+ }));
52
+ };
53
+
54
+ /** Map model-facing numeric citations to immutable Capsule evidence IDs. */
55
+ export const mapAnswerCitationsToEvidence = (
56
+ answer: string,
57
+ capsule: ContextCapsuleV1
58
+ ): string =>
59
+ answer
60
+ .replace(NUMERIC_CITATION_PATTERN, (_marker, rawIndex: string) => {
61
+ const evidence = capsule.evidence[Number(rawIndex) - 1];
62
+ return evidence ? `[evidence:${evidence.evidenceId}]` : "";
63
+ })
64
+ .replace(/ {2,}/g, " ")
65
+ .trim();
66
+
67
+ const retainedCitations = (
68
+ capsule: ContextCapsuleV1,
69
+ evidenceIds: ReadonlySet<string>
70
+ ): Citation[] =>
71
+ capsule.evidence.flatMap((evidence) => {
72
+ if (!evidenceIds.has(evidence.evidenceId)) return [];
73
+ return [
74
+ attachCitationTraceMetadata(
75
+ {
76
+ evidenceId: evidence.evidenceId,
77
+ docid: evidence.docid,
78
+ uri: evidence.uri,
79
+ startLine: evidence.startLine,
80
+ endLine: evidence.endLine,
81
+ },
82
+ {
83
+ sourceHash: evidence.sourceHash,
84
+ mirrorHash: evidence.mirrorHash,
85
+ passageHash: evidence.passageHash,
86
+ rank: evidence.selectionRank,
87
+ plannerRank: evidence.retrievalRank,
88
+ ...(evidence.retrievalSources === undefined
89
+ ? {}
90
+ : { sources: evidence.retrievalSources }),
91
+ ...(evidence.graphExpanded === undefined
92
+ ? {}
93
+ : { graphExpanded: evidence.graphExpanded }),
94
+ }
95
+ ),
96
+ ];
97
+ });
98
+
99
+ const recordRetainedCitations = async (
100
+ traceSession: RetrievalTraceSession | undefined,
101
+ citations: readonly Citation[]
102
+ ): Promise<void> => {
103
+ if (!traceSession || citations.length === 0) return;
104
+ const evidence = citations.flatMap((citation) => {
105
+ const metadata = citation[CITATION_TRACE_METADATA];
106
+ if (
107
+ !metadata ||
108
+ citation.startLine === undefined ||
109
+ citation.endLine === undefined
110
+ ) {
111
+ return [];
112
+ }
113
+ return [
114
+ {
115
+ docid: citation.docid,
116
+ uri: citation.uri,
117
+ sourceHash: metadata.sourceHash,
118
+ mirrorHash: metadata.mirrorHash,
119
+ passageHash: metadata.passageHash,
120
+ startLine: citation.startLine,
121
+ endLine: citation.endLine,
122
+ rank: metadata.rank,
123
+ ...(metadata.plannerRank === undefined
124
+ ? {}
125
+ : { plannerRank: metadata.plannerRank }),
126
+ ...(metadata.sources === undefined
127
+ ? {}
128
+ : { sources: metadata.sources }),
129
+ ...(metadata.graphExpanded === undefined
130
+ ? {}
131
+ : { graphExpanded: metadata.graphExpanded }),
132
+ },
133
+ ];
134
+ });
135
+ if (evidence.length === 0) return;
136
+ const recorded = await traceSession.recordEvidence("cite", evidence);
137
+ if (!recorded.ok) {
138
+ throw new Error(`Trace recording failed: ${recorded.error.message}`);
139
+ }
140
+ };
141
+
142
+ const citationEvidenceIds = (
143
+ verification: Awaited<ReturnType<typeof verifyClaimsSemantically>>,
144
+ statuses: ReadonlySet<"supported" | "contradicted">
145
+ ): Set<string> =>
146
+ new Set(
147
+ verification.verification.claims.flatMap((claim) =>
148
+ statuses.has(claim.status as "supported" | "contradicted")
149
+ ? claim.evidence.map((evidence) => evidence.evidenceId)
150
+ : []
151
+ )
152
+ );
153
+
154
+ const recordCapability = async (
155
+ traceSession: RetrievalTraceSession | undefined,
156
+ capability: string,
157
+ status: "attempted" | "used" | "unavailable" | "failed",
158
+ reasonCode?: string
159
+ ): Promise<void> => {
160
+ const recorded = await traceSession?.recordCapability(
161
+ capability,
162
+ status,
163
+ reasonCode
164
+ );
165
+ if (recorded && !recorded.ok) {
166
+ throw new Error(`Trace recording failed: ${recorded.error.message}`);
167
+ }
168
+ };
169
+
170
+ export const buildVerifiedAsk = async (
171
+ query: string,
172
+ options: AskOptions,
173
+ deps: VerifiedAskDeps
174
+ ): Promise<AskResult> => {
175
+ const collection = options.collection;
176
+ const capsule = await buildContextCapsule(
177
+ {
178
+ goal: query,
179
+ query,
180
+ indexName: deps.indexName,
181
+ collections: collection ? [collection] : [],
182
+ queryModes: options.queryModes,
183
+ tagsAll: options.tagsAll,
184
+ tagsAny: options.tagsAny,
185
+ categories: options.categories,
186
+ author: options.author,
187
+ lang: options.lang,
188
+ intent: options.intent,
189
+ exclude: options.exclude,
190
+ minScore: options.minScore,
191
+ since: options.since,
192
+ until: options.until,
193
+ graph: Boolean(options.graph && !options.noGraph),
194
+ noRerank: options.noRerank,
195
+ limit: options.limit ?? DEFAULT_VERIFIED_ASK_LIMIT,
196
+ candidateLimit: options.candidateLimit,
197
+ budgetTokens:
198
+ options.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS,
199
+ budgetBytes: options.contextBudgetBytes,
200
+ depthPolicy: "balanced",
201
+ },
202
+ deps
203
+ );
204
+ const freshness = await verifyContextCapsuleRuntime(capsule, deps);
205
+ return synthesizeVerifiedAsk(query, options, capsule, freshness, deps);
206
+ };
207
+
208
+ export const synthesizeVerifiedAsk = async (
209
+ query: string,
210
+ options: AskOptions,
211
+ capsule: ContextCapsuleV1,
212
+ freshness: ContextCapsuleVerification,
213
+ deps: Pick<
214
+ VerifiedAskDeps,
215
+ "config" | "genPort" | "indexName" | "traceSession"
216
+ >
217
+ ): Promise<AskResult> => {
218
+ await recordCapability(deps.traceSession, "answer_generation", "attempted");
219
+ const generated = await deps.genPort.generate(
220
+ buildAnswerPrompt(query, generationSources(capsule)),
221
+ {
222
+ temperature: 0,
223
+ maxTokens: options.maxAnswerTokens ?? DEFAULT_MAX_ANSWER_TOKENS,
224
+ }
225
+ );
226
+ await recordCapability(
227
+ deps.traceSession,
228
+ "answer_generation",
229
+ generated.ok ? "used" : "failed",
230
+ generated.ok ? undefined : "generation_failed"
231
+ );
232
+ const draftAnswer = generated.ok
233
+ ? mapAnswerCitationsToEvidence(generated.value, capsule)
234
+ : CLAIM_ABSTENTION_TEXT;
235
+ const verification = await verifyClaimsSemantically({
236
+ answer: draftAnswer,
237
+ capsule,
238
+ freshness,
239
+ genPort: generated.ok ? deps.genPort : null,
240
+ configFingerprint: contextRuntimeConfigFingerprint(deps),
241
+ });
242
+ await recordCapability(
243
+ deps.traceSession,
244
+ "claim_verification",
245
+ verification.semanticVerification.status === "completed"
246
+ ? "used"
247
+ : verification.semanticVerification.status,
248
+ verification.semanticVerification.reason
249
+ );
250
+ const citations = verification.verification.abstained
251
+ ? []
252
+ : retainedCitations(
253
+ capsule,
254
+ citationEvidenceIds(verification, new Set(["supported"]))
255
+ );
256
+ const traceCitations = retainedCitations(
257
+ capsule,
258
+ citationEvidenceIds(verification, new Set(["supported", "contradicted"]))
259
+ );
260
+ await recordRetainedCitations(deps.traceSession, traceCitations);
261
+ return {
262
+ query,
263
+ mode: capsule.capabilities.semanticSearch ? "hybrid" : "bm25_only",
264
+ queryLanguage: capsule.retrieval.request.lang ?? "und",
265
+ answer: verification.verification.abstained
266
+ ? (verification.verification.abstentionText ?? CLAIM_ABSTENTION_TEXT)
267
+ : draftAnswer,
268
+ citations,
269
+ results: [],
270
+ meta: {
271
+ expanded: false,
272
+ reranked: capsule.capabilities.reranking,
273
+ vectorsUsed: capsule.capabilities.semanticSearch,
274
+ intent: capsule.retrieval.request.intent ?? undefined,
275
+ candidateLimit: capsule.retrieval.request.candidateLimit,
276
+ exclude: capsule.retrieval.request.exclude,
277
+ answerGenerated: generated.ok,
278
+ totalResults: capsule.evidence.length,
279
+ verificationRequested: true,
280
+ abstained: verification.verification.abstained,
281
+ },
282
+ verification: {
283
+ schemaVersion: "1.0",
284
+ mode: "closed_capsule",
285
+ capsule,
286
+ freshness,
287
+ claims: verification.verification,
288
+ semantic: verification.semanticVerification,
289
+ },
290
+ };
291
+ };
@@ -0,0 +1,255 @@
1
+ import type { AskResult } from "../../pipeline/types";
2
+ import type { AskCommandOptions, AskCommandResult } from "./ask";
3
+
4
+ interface FormatOptions {
5
+ showSources?: boolean;
6
+ }
7
+
8
+ const exactSpan = (uri: string, startLine: number, endLine: number): string =>
9
+ `${uri}:L${startLine}${startLine === endLine ? "" : `-L${endLine}`}`;
10
+
11
+ const replaceEvidenceMarkers = (value: string, data: AskResult): string => {
12
+ const citationNumbers = new Map(
13
+ (data.citations ?? []).flatMap((citation, index) =>
14
+ citation.evidenceId ? [[citation.evidenceId, index + 1] as const] : []
15
+ )
16
+ );
17
+ return value.replace(
18
+ /\[evidence:([a-f0-9]{64})\]/g,
19
+ (marker, evidenceId: string) => {
20
+ const citationNumber = citationNumbers.get(evidenceId);
21
+ return citationNumber === undefined ? marker : `[${citationNumber}]`;
22
+ }
23
+ );
24
+ };
25
+
26
+ const readableAnswer = (data: AskResult): string | undefined =>
27
+ data.answer ? replaceEvidenceMarkers(data.answer, data) : undefined;
28
+
29
+ const capsuleEvidence = (data: AskResult) =>
30
+ data.verification?.capsule.evidence ?? [];
31
+
32
+ const terminalVerification = (
33
+ verification: NonNullable<AskResult["verification"]>
34
+ ): string[] => {
35
+ const { claims, capsule, semantic } = verification;
36
+ const lines = [
37
+ "Verification:",
38
+ ` Answer status: ${claims.answerStatus}`,
39
+ ` Coverage: ${claims.coverage.supportedClaims}/${claims.coverage.totalClaims} supported (${(claims.coverage.supportedRatio * 100).toFixed(0)}%)`,
40
+ ` Semantic verifier: ${semantic.status} (${semantic.reason})`,
41
+ ];
42
+ if (claims.abstentionReason) {
43
+ lines.push(` Abstention: ${claims.abstentionReason}`);
44
+ }
45
+ for (const [index, claim] of claims.claims.entries()) {
46
+ lines.push(` Claim ${index + 1} [${claim.status}]: ${claim.text}`);
47
+ for (const evidence of claim.evidence) {
48
+ lines.push(
49
+ ` Evidence: ${exactSpan(evidence.uri, evidence.startLine, evidence.endLine)}`
50
+ );
51
+ }
52
+ }
53
+ const degraded = Object.entries(capsule.retrieval.capabilityStates).filter(
54
+ ([, state]) => state.requested && state.outcome !== "used"
55
+ );
56
+ for (const [name, state] of degraded) {
57
+ const reasons =
58
+ state.fallbackReasons.length > 0
59
+ ? ` (${state.fallbackReasons.join(", ")})`
60
+ : "";
61
+ lines.push(` Capability: ${name} ${state.outcome}${reasons}`);
62
+ }
63
+ for (const facet of capsule.coverage.unresolvedFacets) {
64
+ lines.push(` Gap: unresolved facet ${facet}`);
65
+ }
66
+ for (const gap of capsule.coverage.gaps) {
67
+ lines.push(` Gap: ${gap.facet} (${gap.code})`);
68
+ }
69
+ lines.push("");
70
+ return lines;
71
+ };
72
+
73
+ const markdownVerification = (
74
+ verification: NonNullable<AskResult["verification"]>
75
+ ): string[] => {
76
+ const { claims, capsule, semantic } = verification;
77
+ const lines = [
78
+ "## Verification",
79
+ "",
80
+ `- Answer status: **${claims.answerStatus}**`,
81
+ `- Coverage: **${claims.coverage.supportedClaims}/${claims.coverage.totalClaims}** supported (${(claims.coverage.supportedRatio * 100).toFixed(0)}%)`,
82
+ `- Semantic verifier: **${semantic.status}** (\`${semantic.reason}\`)`,
83
+ ];
84
+ if (claims.abstentionReason) {
85
+ lines.push(`- Abstention: \`${claims.abstentionReason}\``);
86
+ }
87
+ lines.push("");
88
+ for (const [index, claim] of claims.claims.entries()) {
89
+ lines.push(`### Claim ${index + 1}: ${claim.status}`);
90
+ lines.push("");
91
+ lines.push(claim.text);
92
+ lines.push("");
93
+ for (const evidence of claim.evidence) {
94
+ lines.push(
95
+ `- Evidence: \`${exactSpan(evidence.uri, evidence.startLine, evidence.endLine)}\``
96
+ );
97
+ }
98
+ if (claim.evidence.length === 0) {
99
+ lines.push("- Evidence: none retained");
100
+ }
101
+ lines.push("");
102
+ }
103
+ const degraded = Object.entries(capsule.retrieval.capabilityStates).filter(
104
+ ([, state]) => state.requested && state.outcome !== "used"
105
+ );
106
+ if (
107
+ degraded.length > 0 ||
108
+ capsule.coverage.unresolvedFacets.length > 0 ||
109
+ capsule.coverage.gaps.length > 0
110
+ ) {
111
+ lines.push("### Gaps and degradation");
112
+ lines.push("");
113
+ for (const [name, state] of degraded) {
114
+ const reasons =
115
+ state.fallbackReasons.length > 0
116
+ ? `: ${state.fallbackReasons.join(", ")}`
117
+ : "";
118
+ lines.push(`- Capability \`${name}\`: ${state.outcome}${reasons}`);
119
+ }
120
+ for (const facet of capsule.coverage.unresolvedFacets) {
121
+ lines.push(`- Unresolved facet: ${facet}`);
122
+ }
123
+ for (const gap of capsule.coverage.gaps) {
124
+ lines.push(`- Gap: ${gap.facet} (\`${gap.code}\`)`);
125
+ }
126
+ lines.push("");
127
+ }
128
+ return lines;
129
+ };
130
+
131
+ const formatTerminal = (data: AskResult, opts: FormatOptions = {}): string => {
132
+ const lines: string[] = [];
133
+ const hasAnswer = Boolean(data.answer);
134
+ const answer = readableAnswer(data);
135
+ if (answer) {
136
+ lines.push("Answer:", answer, "");
137
+ }
138
+ if (data.verification) {
139
+ lines.push(...terminalVerification(data.verification));
140
+ }
141
+ if (data.citations && data.citations.length > 0) {
142
+ lines.push("Cited Sources:");
143
+ for (const [index, citation] of data.citations.entries()) {
144
+ const range =
145
+ citation.startLine === undefined
146
+ ? ""
147
+ : `:L${citation.startLine}${citation.endLine === undefined || citation.endLine === citation.startLine ? "" : `-L${citation.endLine}`}`;
148
+ lines.push(` [${index + 1}] ${citation.uri}${range}`);
149
+ }
150
+ lines.push("");
151
+ }
152
+ const showAllSources = !hasAnswer || opts.showSources;
153
+ const verifiedEvidence = capsuleEvidence(data);
154
+ if (showAllSources && verifiedEvidence.length > 0) {
155
+ lines.push("All Capsule Evidence:");
156
+ for (const evidence of verifiedEvidence) {
157
+ lines.push(
158
+ ` [${evidence.docid}] ${exactSpan(evidence.uri, evidence.startLine, evidence.endLine)}`
159
+ );
160
+ if (evidence.title) lines.push(` ${evidence.title}`);
161
+ }
162
+ } else if (showAllSources && data.results.length > 0) {
163
+ lines.push(hasAnswer ? "All Retrieved Sources:" : "Sources:");
164
+ for (const result of data.results) {
165
+ lines.push(` [${result.docid}] ${result.uri}`);
166
+ if (result.title) lines.push(` ${result.title}`);
167
+ }
168
+ } else if (hasAnswer && data.results.length > 0) {
169
+ const citedCount = data.citations?.length ?? 0;
170
+ if (data.results.length > citedCount) {
171
+ lines.push(
172
+ `(${data.results.length} sources retrieved, use --show-sources to list all)`
173
+ );
174
+ }
175
+ }
176
+ if (!data.answer && data.results.length === 0) {
177
+ lines.push("No relevant sources found.");
178
+ }
179
+ return replaceEvidenceMarkers(lines.join("\n"), data);
180
+ };
181
+
182
+ const formatMarkdown = (data: AskResult, opts: FormatOptions = {}): string => {
183
+ const lines: string[] = [`# Question: ${data.query}`, ""];
184
+ const hasAnswer = Boolean(data.answer);
185
+ const answer = readableAnswer(data);
186
+ if (answer) {
187
+ lines.push("## Answer", "", answer, "");
188
+ }
189
+ if (data.verification) {
190
+ lines.push(...markdownVerification(data.verification));
191
+ }
192
+ if (data.citations && data.citations.length > 0) {
193
+ lines.push("## Cited Sources", "");
194
+ for (const [index, citation] of data.citations.entries()) {
195
+ const range =
196
+ citation.startLine === undefined
197
+ ? ""
198
+ : `:L${citation.startLine}${citation.endLine === undefined || citation.endLine === citation.startLine ? "" : `-L${citation.endLine}`}`;
199
+ lines.push(`**[${index + 1}]** \`${citation.uri}${range}\``);
200
+ }
201
+ lines.push("");
202
+ }
203
+ if (!hasAnswer || opts.showSources) {
204
+ const verifiedEvidence = capsuleEvidence(data);
205
+ lines.push(
206
+ verifiedEvidence.length > 0
207
+ ? "## All Capsule Evidence"
208
+ : hasAnswer
209
+ ? "## All Retrieved Sources"
210
+ : "## Sources",
211
+ ""
212
+ );
213
+ if (verifiedEvidence.length > 0) {
214
+ for (const [index, evidence] of verifiedEvidence.entries()) {
215
+ lines.push(
216
+ `${index + 1}. **${evidence.title || evidence.uri}**`,
217
+ ` - URI: \`${exactSpan(evidence.uri, evidence.startLine, evidence.endLine)}\``
218
+ );
219
+ }
220
+ } else {
221
+ for (const [index, result] of data.results.entries()) {
222
+ lines.push(
223
+ `${index + 1}. **${result.title || result.source.relPath}**`
224
+ );
225
+ lines.push(` - URI: \`${result.uri}\``);
226
+ lines.push(` - Score: ${result.score.toFixed(2)}`);
227
+ }
228
+ if (data.results.length === 0) lines.push("*No relevant sources found.*");
229
+ }
230
+ }
231
+ lines.push(
232
+ "",
233
+ "---",
234
+ `*Mode: ${data.mode} | Expanded: ${data.meta.expanded} | Reranked: ${data.meta.reranked}*`
235
+ );
236
+ return replaceEvidenceMarkers(lines.join("\n"), data);
237
+ };
238
+
239
+ export function formatAsk(
240
+ result: AskCommandResult,
241
+ options: AskCommandOptions
242
+ ): string {
243
+ if (!result.success) {
244
+ return options.json
245
+ ? JSON.stringify({
246
+ error: { code: "ASK_FAILED", message: result.error },
247
+ })
248
+ : `Error: ${result.error}`;
249
+ }
250
+ if (options.json) return JSON.stringify(result.data, null, 2);
251
+ const formatOptions = { showSources: options.showSources };
252
+ return options.md
253
+ ? formatMarkdown(result.data, formatOptions)
254
+ : formatTerminal(result.data, formatOptions);
255
+ }