@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
@@ -0,0 +1,487 @@
1
+ import type { ContextCapsuleV1 } from "../core/context-capsule";
2
+ import type { ContextCapsuleVerification } from "../core/context-capsule-verification";
3
+
4
+ import {
5
+ contextCapsuleV1Schema,
6
+ parseContextCapsuleV1,
7
+ } from "../core/context-capsule";
8
+ import { sha256Text } from "../core/context-capsule-validation";
9
+ import { contextCapsuleVerificationSchema } from "../core/context-capsule-verification";
10
+ import {
11
+ CLAIM_COORDINATE_SPACE,
12
+ CLAIM_VERIFICATION_SCHEMA_VERSION,
13
+ claimVerificationResultSchema,
14
+ type ClaimVerificationResult,
15
+ type SemanticClaimJudgment,
16
+ semanticClaimJudgmentSchema,
17
+ } from "./claim-verification-schema";
18
+
19
+ const SHA256_PATTERN = /^[a-f0-9]{64}$/;
20
+ const CITATION_PATTERN = /\[evidence(?::([^\]\r\n]*))?(\]?)/g;
21
+ const SUBSTANTIVE_PATTERN = /[\p{L}\p{N}]/u;
22
+ const CLAIM_BOUNDARY_PATTERN = /[.!?;\n]/;
23
+ const TRAILING_CLOSER_PATTERN = /["')\]}’”]/;
24
+ const COVERAGE_THRESHOLD = 1;
25
+ const MAX_CLAIMS = 256;
26
+ const MAX_CITATIONS = 256;
27
+
28
+ export {
29
+ CLAIM_COORDINATE_SPACE,
30
+ CLAIM_VERIFICATION_SCHEMA_VERSION,
31
+ claimVerificationResultSchema,
32
+ semanticClaimJudgmentSchema,
33
+ };
34
+ export const CLAIM_ABSTENTION_TEXT =
35
+ "I cannot provide this answer as verified from the supplied Context Capsule.";
36
+
37
+ export type { SemanticClaimJudgment };
38
+
39
+ export interface ClaimSpan {
40
+ claimId: string;
41
+ text: string;
42
+ start: number;
43
+ end: number;
44
+ }
45
+
46
+ export type ClaimVerificationStatus =
47
+ | "supported"
48
+ | "contradicted"
49
+ | "insufficient"
50
+ | "uncertain";
51
+
52
+ export type RejectedCitationReason =
53
+ | "malformed_citation"
54
+ | "out_of_capsule"
55
+ | "freshness_unavailable"
56
+ | "freshness_receipt_invalid"
57
+ | "freshness_receipt_mismatch"
58
+ | "evidence_stale"
59
+ | "evidence_missing"
60
+ | "orphan_citation";
61
+
62
+ export interface RejectedCitation {
63
+ marker: string;
64
+ start: number;
65
+ end: number;
66
+ evidenceId: string | null;
67
+ reason: RejectedCitationReason;
68
+ }
69
+
70
+ export interface ClaimEvidenceReference {
71
+ evidenceId: string;
72
+ uri: string;
73
+ startLine: number;
74
+ endLine: number;
75
+ text: string;
76
+ sourceHash: string;
77
+ mirrorHash: string;
78
+ passageHash: string;
79
+ }
80
+
81
+ export interface VerifiedClaim extends ClaimSpan {
82
+ status: ClaimVerificationStatus;
83
+ confidence: number | null;
84
+ rationaleCode:
85
+ | "semantic_entailment"
86
+ | "semantic_contradiction"
87
+ | "no_valid_evidence"
88
+ | "semantic_judgment_unavailable";
89
+ verifierFingerprint: string | null;
90
+ evidence: ClaimEvidenceReference[];
91
+ rejectedCitations: RejectedCitation[];
92
+ }
93
+
94
+ export type { ClaimVerificationResult };
95
+
96
+ export interface VerifyClaimsInput {
97
+ answer: string;
98
+ capsule: unknown;
99
+ freshness?: unknown;
100
+ semanticJudgments?: readonly unknown[];
101
+ }
102
+
103
+ const trimSpan = (
104
+ answer: string,
105
+ startInput: number,
106
+ endInput: number
107
+ ): { start: number; end: number } => {
108
+ let start = startInput;
109
+ let end = endInput;
110
+ while (start < end && /\s/.test(answer[start] ?? "")) start += 1;
111
+ while (end > start && /\s/.test(answer[end - 1] ?? "")) end -= 1;
112
+ return { start, end };
113
+ };
114
+
115
+ const citationFreeText = (value: string): string =>
116
+ value.replace(CITATION_PATTERN, "").replace(/^\s*(?:[#>*+-]|\d+[.)])\s+/, "");
117
+
118
+ const isAbbreviationPeriod = (answer: string, index: number): boolean => {
119
+ const previous = answer[index - 1] ?? "";
120
+ const next = answer[index + 1] ?? "";
121
+ if (/\d/.test(previous) && /\d/.test(next)) return true;
122
+ const prefix = answer.slice(Math.max(0, index - 8), index + 1).toLowerCase();
123
+ return /(?:\b(?:dr|mr|mrs|ms|prof|sr|jr|vs|etc)|e\.g|i\.e|u\.s)\.$/.test(
124
+ prefix
125
+ );
126
+ };
127
+
128
+ const fencedCodeEnd = (answer: string, start: number): number => {
129
+ const closing = answer.indexOf("```", start + 3);
130
+ return closing === -1 ? answer.length : closing + 3;
131
+ };
132
+
133
+ const assertCitationLimit = (answer: string): void => {
134
+ CITATION_PATTERN.lastIndex = 0;
135
+ let count = 0;
136
+ for (const _match of answer.matchAll(CITATION_PATTERN)) {
137
+ count += 1;
138
+ if (count > MAX_CITATIONS) {
139
+ throw new Error(`Citation limit exceeded (${MAX_CITATIONS})`);
140
+ }
141
+ }
142
+ };
143
+
144
+ const claimIdentity = (
145
+ capsuleId: string,
146
+ start: number,
147
+ end: number,
148
+ text: string
149
+ ): string => sha256Text(JSON.stringify({ capsuleId, end, start, text }));
150
+
151
+ /** Split substantive claims without rewriting. Offsets are half-open UTF-16. */
152
+ export const segmentSubstantiveClaims = (
153
+ answer: string,
154
+ capsuleId: string
155
+ ): ClaimSpan[] => {
156
+ const claims: ClaimSpan[] = [];
157
+ const appendClaim = (start: number, end: number, text: string): void => {
158
+ claims.push({
159
+ claimId: claimIdentity(capsuleId, start, end, text),
160
+ text,
161
+ start,
162
+ end,
163
+ });
164
+ if (claims.length > MAX_CLAIMS) {
165
+ throw new Error(`Claim limit exceeded (${MAX_CLAIMS})`);
166
+ }
167
+ };
168
+ let segmentStart = 0;
169
+ for (let index = 0; index < answer.length; index += 1) {
170
+ if (answer.startsWith("```", index)) {
171
+ const before = trimSpan(answer, segmentStart, index);
172
+ const beforeText = answer.slice(before.start, before.end);
173
+ if (SUBSTANTIVE_PATTERN.test(citationFreeText(beforeText))) {
174
+ appendClaim(before.start, before.end, beforeText);
175
+ }
176
+ const codeEnd = fencedCodeEnd(answer, index);
177
+ segmentStart = codeEnd;
178
+ index = codeEnd - 1;
179
+ continue;
180
+ }
181
+ const character = answer[index] ?? "";
182
+ if (!CLAIM_BOUNDARY_PATTERN.test(character)) continue;
183
+ if (character === "." && isAbbreviationPeriod(answer, index)) continue;
184
+ let boundary = index + 1;
185
+ while (
186
+ character !== "\n" &&
187
+ boundary < answer.length &&
188
+ TRAILING_CLOSER_PATTERN.test(answer[boundary] ?? "")
189
+ ) {
190
+ boundary += 1;
191
+ }
192
+ const next = answer[boundary];
193
+ if (character !== "\n" && next !== undefined && !/\s/.test(next)) continue;
194
+ const span = trimSpan(answer, segmentStart, boundary);
195
+ const text = answer.slice(span.start, span.end);
196
+ if (SUBSTANTIVE_PATTERN.test(citationFreeText(text))) {
197
+ appendClaim(span.start, span.end, text);
198
+ }
199
+ segmentStart = boundary;
200
+ }
201
+ const span = trimSpan(answer, segmentStart, answer.length);
202
+ const text = answer.slice(span.start, span.end);
203
+ if (SUBSTANTIVE_PATTERN.test(citationFreeText(text))) {
204
+ appendClaim(span.start, span.end, text);
205
+ }
206
+ return claims;
207
+ };
208
+
209
+ type Evidence = ContextCapsuleV1["evidence"][number];
210
+ type EvidenceReceipt = ContextCapsuleVerification["evidence"][number];
211
+
212
+ interface FreshnessState {
213
+ status: "verified" | "unavailable" | "invalid" | "mismatch";
214
+ receipts: Map<string, EvidenceReceipt>;
215
+ }
216
+
217
+ const freshnessState = (
218
+ capsule: ContextCapsuleV1,
219
+ input: unknown
220
+ ): FreshnessState => {
221
+ if (input === undefined)
222
+ return { status: "unavailable", receipts: new Map() };
223
+ const parsed = contextCapsuleVerificationSchema.safeParse(input);
224
+ if (!parsed.success) return { status: "invalid", receipts: new Map() };
225
+ const expected = new Map(
226
+ capsule.evidence.map((evidence) => [evidence.evidenceId, evidence])
227
+ );
228
+ const complete =
229
+ parsed.data.capsuleId === capsule.capsuleId &&
230
+ parsed.data.evidence.length === expected.size &&
231
+ parsed.data.evidence.every(
232
+ (receipt) =>
233
+ expected.get(receipt.evidenceId)?.uri === receipt.uri &&
234
+ (receipt.contentStatus !== "unchanged" ||
235
+ (receipt.currentSourceHash ===
236
+ expected.get(receipt.evidenceId)?.sourceHash &&
237
+ receipt.currentMirrorHash ===
238
+ expected.get(receipt.evidenceId)?.mirrorHash &&
239
+ receipt.currentPassageHash ===
240
+ expected.get(receipt.evidenceId)?.passageHash))
241
+ );
242
+ if (!complete) return { status: "mismatch", receipts: new Map() };
243
+ return {
244
+ status: "verified",
245
+ receipts: new Map(
246
+ parsed.data.evidence.map((receipt) => [receipt.evidenceId, receipt])
247
+ ),
248
+ };
249
+ };
250
+
251
+ const rejectedReason = (
252
+ evidence: Evidence | undefined,
253
+ freshness: FreshnessState
254
+ ): RejectedCitationReason | null => {
255
+ if (!evidence) return "out_of_capsule";
256
+ if (freshness.status === "unavailable") return "freshness_unavailable";
257
+ if (freshness.status === "invalid") return "freshness_receipt_invalid";
258
+ if (freshness.status === "mismatch") return "freshness_receipt_mismatch";
259
+ const receipt = freshness.receipts.get(evidence.evidenceId);
260
+ if (!receipt || receipt.contentStatus === "missing")
261
+ return "evidence_missing";
262
+ if (receipt.contentStatus === "stale") return "evidence_stale";
263
+ return null;
264
+ };
265
+
266
+ const evidenceReference = (evidence: Evidence): ClaimEvidenceReference => ({
267
+ evidenceId: evidence.evidenceId,
268
+ uri: evidence.uri,
269
+ startLine: evidence.startLine,
270
+ endLine: evidence.endLine,
271
+ text: evidence.text,
272
+ sourceHash: evidence.sourceHash,
273
+ mirrorHash: evidence.mirrorHash,
274
+ passageHash: evidence.passageHash,
275
+ });
276
+
277
+ const citationsForClaim = (
278
+ answer: string,
279
+ claim: ClaimSpan,
280
+ evidenceById: ReadonlyMap<string, Evidence>,
281
+ freshness: FreshnessState
282
+ ): { accepted: Evidence[]; rejected: RejectedCitation[] } => {
283
+ const accepted = new Map<string, Evidence>();
284
+ const rejected: RejectedCitation[] = [];
285
+ CITATION_PATTERN.lastIndex = claim.start;
286
+ for (const match of answer.matchAll(CITATION_PATTERN)) {
287
+ const start = match.index;
288
+ const end = start + match[0].length;
289
+ if (start >= claim.end) break;
290
+ if (start < claim.start) continue;
291
+ const rawId = match[1] ?? "";
292
+ const closed = match[2] === "]";
293
+ const evidenceId = closed && SHA256_PATTERN.test(rawId) ? rawId : null;
294
+ const evidence =
295
+ evidenceId === null ? undefined : evidenceById.get(evidenceId);
296
+ const reason =
297
+ evidenceId === null
298
+ ? "malformed_citation"
299
+ : rejectedReason(evidence, freshness);
300
+ if (reason) {
301
+ rejected.push({
302
+ marker: match[0],
303
+ start,
304
+ end,
305
+ evidenceId,
306
+ reason,
307
+ });
308
+ } else if (evidence) {
309
+ accepted.set(evidence.evidenceId, evidence);
310
+ }
311
+ if (accepted.size + rejected.length > MAX_CITATIONS) {
312
+ throw new Error(`Citation limit exceeded (${MAX_CITATIONS})`);
313
+ }
314
+ }
315
+ return { accepted: [...accepted.values()], rejected };
316
+ };
317
+
318
+ const judgmentForClaim = (
319
+ claim: ClaimSpan,
320
+ acceptedIds: ReadonlySet<string>,
321
+ inputs: readonly unknown[]
322
+ ): SemanticClaimJudgment | null => {
323
+ const matches: SemanticClaimJudgment[] = [];
324
+ for (const input of inputs) {
325
+ const parsed = semanticClaimJudgmentSchema.safeParse(input);
326
+ if (
327
+ parsed.success &&
328
+ parsed.data.claimId === claim.claimId &&
329
+ parsed.data.evidenceIds.every((id) => acceptedIds.has(id))
330
+ ) {
331
+ matches.push(parsed.data);
332
+ }
333
+ }
334
+ return matches.length === 1 ? (matches[0] ?? null) : null;
335
+ };
336
+
337
+ const verifyClaim = (
338
+ answer: string,
339
+ claim: ClaimSpan,
340
+ evidenceById: ReadonlyMap<string, Evidence>,
341
+ freshness: FreshnessState,
342
+ judgments: readonly unknown[]
343
+ ): VerifiedClaim => {
344
+ const citations = citationsForClaim(answer, claim, evidenceById, freshness);
345
+ const references = citations.accepted.map(evidenceReference);
346
+ const acceptedIds = new Set(references.map((item) => item.evidenceId));
347
+ const judgment = judgmentForClaim(claim, acceptedIds, judgments);
348
+ if (references.length === 0) {
349
+ return {
350
+ ...claim,
351
+ status: "insufficient",
352
+ confidence: null,
353
+ rationaleCode: "no_valid_evidence",
354
+ verifierFingerprint: null,
355
+ evidence: [],
356
+ rejectedCitations: citations.rejected,
357
+ };
358
+ }
359
+ if (!judgment) {
360
+ return {
361
+ ...claim,
362
+ status: "uncertain",
363
+ confidence: null,
364
+ rationaleCode: "semantic_judgment_unavailable",
365
+ verifierFingerprint: null,
366
+ evidence: references,
367
+ rejectedCitations: citations.rejected,
368
+ };
369
+ }
370
+ const selected = new Set(judgment.evidenceIds);
371
+ return {
372
+ ...claim,
373
+ status: judgment.verdict,
374
+ confidence: judgment.confidence,
375
+ rationaleCode: judgment.rationaleCode,
376
+ verifierFingerprint: judgment.verifierFingerprint,
377
+ evidence: references.filter((item) => selected.has(item.evidenceId)),
378
+ rejectedCitations: citations.rejected,
379
+ };
380
+ };
381
+
382
+ const orphanCitations = (
383
+ answer: string,
384
+ claims: readonly ClaimSpan[],
385
+ evidenceById: ReadonlyMap<string, Evidence>,
386
+ freshness: FreshnessState
387
+ ): RejectedCitation[] => {
388
+ const rejected: RejectedCitation[] = [];
389
+ CITATION_PATTERN.lastIndex = 0;
390
+ for (const match of answer.matchAll(CITATION_PATTERN)) {
391
+ const start = match.index;
392
+ const end = start + match[0].length;
393
+ if (claims.some((claim) => start >= claim.start && start < claim.end)) {
394
+ continue;
395
+ }
396
+ const rawId = match[1] ?? "";
397
+ const evidenceId =
398
+ match[2] === "]" && SHA256_PATTERN.test(rawId) ? rawId : null;
399
+ const evidence =
400
+ evidenceId === null ? undefined : evidenceById.get(evidenceId);
401
+ rejected.push({
402
+ marker: match[0],
403
+ start,
404
+ end,
405
+ evidenceId,
406
+ reason:
407
+ evidenceId === null
408
+ ? "malformed_citation"
409
+ : (rejectedReason(evidence, freshness) ?? "orphan_citation"),
410
+ });
411
+ if (rejected.length > MAX_CITATIONS) {
412
+ throw new Error(`Citation limit exceeded (${MAX_CITATIONS})`);
413
+ }
414
+ }
415
+ return rejected;
416
+ };
417
+
418
+ export const verifyClaimsDeterministically = (
419
+ input: VerifyClaimsInput
420
+ ): ClaimVerificationResult => {
421
+ if ((input.semanticJudgments?.length ?? 0) > MAX_CLAIMS) {
422
+ throw new Error(`Semantic judgment limit exceeded (${MAX_CLAIMS})`);
423
+ }
424
+ const capsule = parseContextCapsuleV1(input.capsule);
425
+ // A second strict parse makes the identity and nested contract requirement
426
+ // explicit at this pipeline boundary.
427
+ contextCapsuleV1Schema.parse(capsule);
428
+ assertCitationLimit(input.answer);
429
+ const claims = segmentSubstantiveClaims(input.answer, capsule.capsuleId);
430
+ const freshness = freshnessState(capsule, input.freshness);
431
+ const evidenceById = new Map(
432
+ capsule.evidence.map((evidence) => [evidence.evidenceId, evidence])
433
+ );
434
+ const verifiedClaims = claims.map((claim) =>
435
+ verifyClaim(
436
+ input.answer,
437
+ claim,
438
+ evidenceById,
439
+ freshness,
440
+ input.semanticJudgments ?? []
441
+ )
442
+ );
443
+ const rejectedCitations = orphanCitations(
444
+ input.answer,
445
+ claims,
446
+ evidenceById,
447
+ freshness
448
+ );
449
+ const count = (status: ClaimVerificationStatus): number =>
450
+ verifiedClaims.filter((claim) => claim.status === status).length;
451
+ const supportedClaims = count("supported");
452
+ const coverage = {
453
+ totalClaims: verifiedClaims.length,
454
+ supportedClaims,
455
+ contradictedClaims: count("contradicted"),
456
+ insufficientClaims: count("insufficient"),
457
+ uncertainClaims: count("uncertain"),
458
+ supportedRatio:
459
+ verifiedClaims.length === 0 ? 0 : supportedClaims / verifiedClaims.length,
460
+ };
461
+ const abstentionReason =
462
+ coverage.totalClaims === 0
463
+ ? "no_substantive_claims"
464
+ : coverage.contradictedClaims > 0
465
+ ? "contradiction_detected"
466
+ : rejectedCitations.length > 0 ||
467
+ verifiedClaims.some((claim) => claim.rejectedCitations.length > 0)
468
+ ? "citation_hygiene_failed"
469
+ : coverage.supportedRatio < COVERAGE_THRESHOLD
470
+ ? "coverage_below_threshold"
471
+ : null;
472
+ const abstained = abstentionReason !== null;
473
+ return claimVerificationResultSchema.parse({
474
+ schemaVersion: CLAIM_VERIFICATION_SCHEMA_VERSION,
475
+ coordinateSpace: CLAIM_COORDINATE_SPACE,
476
+ capsuleId: capsule.capsuleId,
477
+ answerHash: sha256Text(input.answer),
478
+ coverageThreshold: COVERAGE_THRESHOLD,
479
+ claims: verifiedClaims,
480
+ rejectedCitations,
481
+ coverage,
482
+ answerStatus: abstained ? "abstained" : "verified",
483
+ abstained,
484
+ abstentionReason,
485
+ abstentionText: abstained ? CLAIM_ABSTENTION_TEXT : null,
486
+ });
487
+ };