@gmickel/gno 1.19.0 → 1.21.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 (85) hide show
  1. package/README.md +28 -8
  2. package/assets/skill/SKILL.md +73 -27
  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 +142 -17
  7. package/spec/db/schema.sql +170 -0
  8. package/spec/evals-agentic.md +87 -5
  9. package/spec/mcp.md +75 -3
  10. package/spec/output-schemas/ask.schema.json +198 -0
  11. package/spec/output-schemas/capsule-reverified-event.schema.json +47 -0
  12. package/spec/output-schemas/changes.schema.json +280 -0
  13. package/spec/output-schemas/claim-verification.schema.json +291 -0
  14. package/spec/output-schemas/context-capsule-v1.schema.json +36 -1
  15. package/spec/output-schemas/document-diff.schema.json +185 -0
  16. package/spec/output-schemas/impact.schema.json +122 -0
  17. package/spec/output-schemas/saved-capsule-list.schema.json +16 -0
  18. package/spec/output-schemas/saved-capsule-registration.schema.json +172 -0
  19. package/spec/output-schemas/saved-capsule-reverification.schema.json +59 -0
  20. package/spec/output-schemas/saved-capsule-unwatch.schema.json +16 -0
  21. package/spec/output-schemas/saved-capsule-watch.schema.json +17 -0
  22. package/src/app/context-runtime-contract.ts +10 -5
  23. package/src/app/context-runtime-input.ts +29 -1
  24. package/src/app/context-runtime-types.ts +4 -0
  25. package/src/app/context-runtime.ts +5 -1
  26. package/src/app/context-surface.ts +4 -0
  27. package/src/app/verified-ask.ts +291 -0
  28. package/src/cli/commands/ask-format.ts +255 -0
  29. package/src/cli/commands/ask.ts +40 -149
  30. package/src/cli/commands/changes.ts +160 -0
  31. package/src/cli/commands/context-saved.ts +189 -0
  32. package/src/cli/options.ts +8 -0
  33. package/src/cli/program.ts +227 -1
  34. package/src/core/capsule-registry.ts +279 -0
  35. package/src/core/capsule-reverification-scheduler.ts +218 -0
  36. package/src/core/capsule-reverification.ts +289 -0
  37. package/src/core/change-diff.ts +182 -0
  38. package/src/core/change-journal.ts +228 -0
  39. package/src/core/context-budget.ts +6 -0
  40. package/src/core/context-capsule-retrieval-schema.ts +4 -0
  41. package/src/core/context-capsule-schema.ts +17 -0
  42. package/src/core/context-capsule-validation.ts +3 -2
  43. package/src/core/context-capsule.ts +18 -0
  44. package/src/core/context-compiler.ts +33 -21
  45. package/src/core/context-evidence.ts +6 -0
  46. package/src/core/knowledge-delta.ts +395 -0
  47. package/src/core/knowledge-impact.ts +202 -0
  48. package/src/core/retrieval-trace-evidence-origin.ts +3 -0
  49. package/src/core/retrieval-trace-session.ts +15 -2
  50. package/src/ingestion/sync.ts +214 -165
  51. package/src/llm/errors.ts +10 -1
  52. package/src/llm/httpGeneration.ts +11 -1
  53. package/src/llm/nodeLlamaCpp/generation.ts +54 -10
  54. package/src/llm/types.ts +6 -0
  55. package/src/mcp/tools/ask.ts +228 -0
  56. package/src/mcp/tools/changes.ts +80 -0
  57. package/src/mcp/tools/context.ts +28 -7
  58. package/src/mcp/tools/index.ts +38 -0
  59. package/src/pipeline/claim-verification-schema.ts +235 -0
  60. package/src/pipeline/claim-verification.ts +487 -0
  61. package/src/pipeline/claim-verifier.ts +474 -0
  62. package/src/pipeline/types.ts +25 -0
  63. package/src/sdk/client.ts +77 -2
  64. package/src/sdk/index.ts +7 -0
  65. package/src/sdk/types.ts +22 -0
  66. package/src/serve/doc-events.ts +12 -1
  67. package/src/serve/public/components/AskVerificationPanel.tsx +189 -0
  68. package/src/serve/public/globals.built.css +1 -1
  69. package/src/serve/public/pages/Ask.tsx +42 -4
  70. package/src/serve/resident-runtime.ts +22 -0
  71. package/src/serve/routes/api.ts +162 -3
  72. package/src/serve/routes/changes.ts +102 -0
  73. package/src/serve/server.ts +34 -0
  74. package/src/serve/watch-service.ts +9 -0
  75. package/src/store/index.ts +21 -0
  76. package/src/store/migrations/015-document-change-journal.ts +85 -0
  77. package/src/store/migrations/016-saved-capsules.ts +131 -0
  78. package/src/store/migrations/017-document-change-retention-counters.ts +33 -0
  79. package/src/store/migrations/018-saved-capsule-registration-epoch.ts +24 -0
  80. package/src/store/migrations/019-saved-capsule-registration-generation.ts +53 -0
  81. package/src/store/migrations/index.ts +10 -0
  82. package/src/store/sqlite/adapter.ts +291 -7
  83. package/src/store/sqlite/capsule-registry-store.ts +534 -0
  84. package/src/store/sqlite/change-journal-store.ts +473 -0
  85. package/src/store/types.ts +262 -0
@@ -315,16 +315,40 @@ export const normalizeContextBuildInput = (
315
315
  "Context graph flag must be boolean"
316
316
  );
317
317
  }
318
+ if (input.noRerank !== undefined && typeof input.noRerank !== "boolean") {
319
+ throw new ContextRuntimeError(
320
+ "invalid_filter",
321
+ "Context noRerank flag must be boolean"
322
+ );
323
+ }
324
+ if (
325
+ input.minScore !== undefined &&
326
+ (typeof input.minScore !== "number" ||
327
+ !Number.isFinite(input.minScore) ||
328
+ input.minScore < 0 ||
329
+ input.minScore > 1)
330
+ ) {
331
+ throw new ContextRuntimeError(
332
+ "invalid_filter",
333
+ "Context minimum score must be between 0 and 1"
334
+ );
335
+ }
318
336
  const author =
319
337
  typeof input.author === "string"
320
338
  ? input.author.normalize("NFC").trim()
321
339
  : null;
322
340
  const lang =
323
341
  typeof input.lang === "string" ? input.lang.normalize("NFC").trim() : null;
342
+ const intent =
343
+ typeof input.intent === "string"
344
+ ? input.intent.normalize("NFC").trim()
345
+ : null;
324
346
  if (
325
347
  (input.author !== undefined &&
326
348
  (!author || author.length > MAX_FILTER_LENGTH)) ||
327
- (input.lang !== undefined && (!lang || !isValidLanguageHint(lang)))
349
+ (input.lang !== undefined && (!lang || !isValidLanguageHint(lang))) ||
350
+ (input.intent !== undefined &&
351
+ (!intent || intent.length > MAX_TEXT_LENGTH || intent.includes("\r")))
328
352
  ) {
329
353
  throw new ContextRuntimeError(
330
354
  "invalid_filter",
@@ -344,9 +368,13 @@ export const normalizeContextBuildInput = (
344
368
  categories: canonicalFilters(input.categories, "categories"),
345
369
  author,
346
370
  lang,
371
+ intent,
372
+ exclude: canonicalFilters(input.exclude, "exclude"),
373
+ minScore: input.minScore ?? null,
347
374
  since: temporalRange.since,
348
375
  until: temporalRange.until,
349
376
  graph: input.graph ?? false,
377
+ noRerank: input.noRerank ?? false,
350
378
  limit,
351
379
  candidateLimit,
352
380
  budgetTokens,
@@ -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
+ }