@gmickel/gno 1.15.0 → 1.17.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 (46) hide show
  1. package/README.md +21 -17
  2. package/assets/skill/SKILL.md +8 -5
  3. package/package.json +2 -1
  4. package/src/app/context-agent-projection.ts +303 -0
  5. package/src/app/context-format.ts +249 -0
  6. package/src/app/context-runtime-contract.ts +325 -0
  7. package/src/app/context-runtime-input.ts +362 -0
  8. package/src/app/context-runtime-types.ts +65 -0
  9. package/src/app/context-runtime.ts +170 -0
  10. package/src/app/context-surface.ts +145 -0
  11. package/src/cli/commands/context-build.ts +149 -0
  12. package/src/cli/commands/context-verify.ts +90 -0
  13. package/src/cli/options.ts +4 -0
  14. package/src/cli/program.ts +178 -0
  15. package/src/core/context-budget.ts +461 -0
  16. package/src/core/context-capsule-index-schema.ts +15 -0
  17. package/src/core/context-capsule-retrieval-schema.ts +81 -0
  18. package/src/core/context-capsule-schema.ts +473 -0
  19. package/src/core/context-capsule-validation.ts +416 -0
  20. package/src/core/context-capsule-verification.ts +218 -0
  21. package/src/core/context-capsule.ts +439 -0
  22. package/src/core/context-compiler.ts +513 -0
  23. package/src/core/context-evidence-metadata.ts +33 -0
  24. package/src/core/context-evidence.ts +495 -0
  25. package/src/core/context-facets.ts +163 -0
  26. package/src/core/context-guidance.ts +69 -0
  27. package/src/core/context-scope.ts +32 -0
  28. package/src/core/context-verifier-canonical.ts +90 -0
  29. package/src/core/context-verifier-input.ts +66 -0
  30. package/src/core/context-verifier.ts +447 -0
  31. package/src/core/sections.ts +63 -0
  32. package/src/mcp/server.ts +10 -4
  33. package/src/mcp/tools/context.ts +229 -0
  34. package/src/mcp/tools/index.ts +27 -0
  35. package/src/pipeline/chunk-lookup.ts +33 -0
  36. package/src/pipeline/hybrid.ts +79 -57
  37. package/src/pipeline/types.ts +14 -0
  38. package/src/sdk/client.ts +68 -6
  39. package/src/sdk/index.ts +21 -0
  40. package/src/sdk/types.ts +24 -0
  41. package/src/serve/background-runtime.ts +1 -0
  42. package/src/serve/context-capsule.ts +136 -0
  43. package/src/serve/context.ts +10 -1
  44. package/src/serve/routes/api.ts +2 -0
  45. package/src/serve/server.ts +23 -0
  46. package/src/store/sqlite/adapter.ts +38 -20
@@ -0,0 +1,513 @@
1
+ /**
2
+ * Deterministic retrieval planning for Context Capsules.
3
+ *
4
+ * Retrieval, strict context snapshot loading, exact line materialization, and
5
+ * canonical payload projection are injected. Indexed text is only compared as
6
+ * untrusted data; it never controls planner behavior.
7
+ */
8
+
9
+ import type {
10
+ FusionSource,
11
+ HybridSearchOptions,
12
+ QueryModeInput,
13
+ SearchMeta,
14
+ SearchResult,
15
+ SearchResults,
16
+ } from "../pipeline/types";
17
+ import type { ContextRow } from "../store/types";
18
+ import type {
19
+ ContextBudgetLimits,
20
+ ContextCandidateReference,
21
+ ContextCanonicalProjection,
22
+ ContextOmission,
23
+ ContextSelectionResult,
24
+ ContextSelectionState,
25
+ MaterializedContextCandidate,
26
+ } from "./context-budget";
27
+ import type { ContextConfiguredGuidance } from "./context-guidance";
28
+
29
+ import { decorateUriForIndex } from "../app/constants";
30
+ import { canonicalizeIndexName } from "../app/index-name";
31
+ import { resolveTemporalRange } from "../pipeline/temporal";
32
+ import {
33
+ SEARCH_RESULT_PLANNER_METADATA,
34
+ type SearchResultPlannerMetadata,
35
+ } from "../pipeline/types";
36
+ import { selectContextEvidence } from "./context-budget";
37
+ import {
38
+ contextCapsuleOmissionIdentity,
39
+ sha256Text,
40
+ } from "./context-capsule-validation";
41
+ import {
42
+ candidateMatchesContextFacet,
43
+ deriveContextFacetPlan,
44
+ normalizeContextText,
45
+ } from "./context-facets";
46
+ import {
47
+ contextGuidanceResultIdentity,
48
+ resolveContextGuidance,
49
+ } from "./context-guidance";
50
+ import { isContextUriInScope } from "./context-scope";
51
+
52
+ export { deriveContextFacets } from "./context-facets";
53
+ export type { ContextConfiguredGuidance } from "./context-guidance";
54
+
55
+ const HASH_PATTERN = /^[a-f0-9]{64}$/;
56
+ const DOCID_PATTERN = /^#[a-f0-9]{6,}$/;
57
+ export interface ContextRetrievalRequest extends HybridSearchOptions {
58
+ query: string;
59
+ collection?: string;
60
+ noExpand: true;
61
+ }
62
+
63
+ export interface ContextRetrievalCandidate {
64
+ result: SearchResult;
65
+ retrievalRank: number;
66
+ retrievalSources: FusionSource[];
67
+ graphExpanded: boolean;
68
+ contextIds: string[];
69
+ observedAt: string | null;
70
+ }
71
+
72
+ export interface ContextMaterializedDraft<T = unknown> {
73
+ uri: string;
74
+ docid: string;
75
+ startLine: number;
76
+ endLine: number;
77
+ text: string;
78
+ sourceHash: string;
79
+ mirrorHash: string;
80
+ value: T;
81
+ }
82
+
83
+ export type ContextMaterialization<T = unknown> =
84
+ | { ok: true; candidate: ContextMaterializedDraft<T> }
85
+ | { ok: false; reference: ContextCandidateReference };
86
+
87
+ export interface ContextCompilerInput {
88
+ goal: string;
89
+ query?: string;
90
+ indexName: string;
91
+ collections: string[];
92
+ uriPrefix?: string | null;
93
+ queryModes?: QueryModeInput[];
94
+ tagsAll?: string[];
95
+ tagsAny?: string[];
96
+ categories?: string[];
97
+ author?: string;
98
+ lang?: string;
99
+ since?: string;
100
+ until?: string;
101
+ graph?: boolean;
102
+ limit?: number;
103
+ candidateLimit?: number;
104
+ /** Frozen once by the caller; never defaulted from wall-clock time. */
105
+ temporalNow: Date | string;
106
+ /** Stable observation boundary. Use null when no durable timestamp exists. */
107
+ observedAt: string | null;
108
+ limits: ContextBudgetLimits;
109
+ /** One caller-owned, successfully loaded context snapshot for this plan. */
110
+ contextSnapshot: ContextRow[];
111
+ }
112
+
113
+ export interface ContextRetrievalPlan {
114
+ facets: string[];
115
+ queryVariants: string[];
116
+ retrievalSources: FusionSource[];
117
+ semanticSearch: boolean;
118
+ reranked: boolean;
119
+ graphExpansion: boolean;
120
+ graphFallbackReasons: string[];
121
+ }
122
+
123
+ export interface ContextCanonicalPlanDraft<T = unknown> {
124
+ goal: string;
125
+ query: string;
126
+ indexName: string;
127
+ collections: string[];
128
+ uriPrefix: string | null;
129
+ limits: ContextBudgetLimits;
130
+ retrieval: ContextRetrievalPlan;
131
+ configuredContexts: ContextConfiguredGuidance[];
132
+ selection: ContextSelectionState<T>;
133
+ }
134
+
135
+ export interface ContextCompilerDeps<T = unknown, P = unknown> {
136
+ retrieve: (request: ContextRetrievalRequest) => Promise<SearchResults>;
137
+ /** Results must align one-for-one with the supplied candidates. */
138
+ materializeCandidates: (
139
+ candidates: ContextRetrievalCandidate[]
140
+ ) => Promise<ContextMaterialization<T>[]>;
141
+ projectCanonical: (
142
+ draft: ContextCanonicalPlanDraft<T>
143
+ ) => ContextCanonicalProjection<P> | null;
144
+ }
145
+
146
+ export interface ContextEvidencePlan<
147
+ T = unknown,
148
+ P = unknown,
149
+ > extends ContextSelectionResult<T, P> {
150
+ goal: string;
151
+ query: string;
152
+ indexName: string;
153
+ collections: string[];
154
+ uriPrefix: string | null;
155
+ retrieval: ContextRetrievalPlan;
156
+ configuredContexts: ContextConfiguredGuidance[];
157
+ }
158
+
159
+ const compareCodeUnits = (left: string, right: string): number => {
160
+ if (left < right) return -1;
161
+ if (left > right) return 1;
162
+ return 0;
163
+ };
164
+
165
+ const plannerMeta = (
166
+ result: SearchResult,
167
+ fallbackRank: number
168
+ ): SearchResultPlannerMetadata =>
169
+ result[SEARCH_RESULT_PLANNER_METADATA] ?? {
170
+ retrievalRank: fallbackRank,
171
+ mirrorHash: result.conversion?.mirrorHash ?? "",
172
+ seq: 0,
173
+ sources: [],
174
+ graphExpanded: false,
175
+ };
176
+
177
+ const compareSearchResults = (
178
+ left: SearchResult,
179
+ right: SearchResult
180
+ ): number =>
181
+ compareCodeUnits(left.uri, right.uri) ||
182
+ (left.snippetRange?.startLine ?? 0) - (right.snippetRange?.startLine ?? 0) ||
183
+ compareCodeUnits(
184
+ left.source.sourceHash ?? "",
185
+ right.source.sourceHash ?? ""
186
+ ) ||
187
+ compareCodeUnits(left.docid, right.docid);
188
+
189
+ const referenceFromResult = (
190
+ result: SearchResult
191
+ ): ContextCandidateReference => {
192
+ const sourceHash = result.source.sourceHash;
193
+ const mirrorHash = result.conversion?.mirrorHash;
194
+ if (
195
+ !sourceHash ||
196
+ !mirrorHash ||
197
+ !HASH_PATTERN.test(sourceHash) ||
198
+ !HASH_PATTERN.test(mirrorHash) ||
199
+ !DOCID_PATTERN.test(result.docid)
200
+ ) {
201
+ throw new Error(`Hybrid result lacks canonical provenance: ${result.uri}`);
202
+ }
203
+ const startLine = result.snippetRange?.startLine ?? null;
204
+ const endLine = result.snippetRange?.endLine ?? null;
205
+ const passageHash = startLine && endLine ? sha256Text(result.snippet) : null;
206
+ const base = {
207
+ uri: result.uri,
208
+ docid: result.docid,
209
+ startLine,
210
+ endLine,
211
+ passageHash,
212
+ sourceHash,
213
+ mirrorHash,
214
+ };
215
+ return { candidateId: contextCapsuleOmissionIdentity(base), ...base };
216
+ };
217
+
218
+ const normalizeMaterialized = <T>(
219
+ draft: ContextMaterializedDraft<T>,
220
+ facets: string[],
221
+ retrievalRank: number
222
+ ): MaterializedContextCandidate<T> => {
223
+ const text = draft.text;
224
+ if (
225
+ !text ||
226
+ text.includes("\r") ||
227
+ draft.startLine < 1 ||
228
+ draft.endLine < draft.startLine ||
229
+ text.split("\n").length !== draft.endLine - draft.startLine + 1 ||
230
+ !HASH_PATTERN.test(draft.sourceHash) ||
231
+ !HASH_PATTERN.test(draft.mirrorHash) ||
232
+ !DOCID_PATTERN.test(draft.docid)
233
+ ) {
234
+ throw new Error(`Invalid materialized Context coordinates: ${draft.uri}`);
235
+ }
236
+ const passageHash = sha256Text(text);
237
+ const base = {
238
+ uri: draft.uri,
239
+ docid: draft.docid,
240
+ startLine: draft.startLine,
241
+ endLine: draft.endLine,
242
+ passageHash,
243
+ sourceHash: draft.sourceHash,
244
+ mirrorHash: draft.mirrorHash,
245
+ };
246
+ return {
247
+ candidateId: contextCapsuleOmissionIdentity(base),
248
+ ...base,
249
+ text,
250
+ facets,
251
+ retrievalRank,
252
+ value: draft.value,
253
+ };
254
+ };
255
+
256
+ const hasSameSourceIdentity = (
257
+ value: Pick<
258
+ ContextCandidateReference,
259
+ "uri" | "docid" | "sourceHash" | "mirrorHash"
260
+ >,
261
+ reference: ContextCandidateReference
262
+ ): boolean =>
263
+ value.uri === reference.uri &&
264
+ value.docid === reference.docid &&
265
+ value.sourceHash === reference.sourceHash &&
266
+ value.mirrorHash === reference.mirrorHash;
267
+
268
+ const mergeMeta = (
269
+ metas: SearchMeta[]
270
+ ): Omit<
271
+ ContextRetrievalPlan,
272
+ "facets" | "queryVariants" | "retrievalSources"
273
+ > => ({
274
+ semanticSearch: metas.some((meta) => meta.vectorsUsed === true),
275
+ reranked: metas.some((meta) => meta.reranked === true),
276
+ graphExpansion: metas.some((meta) => meta.graphExpansion?.enabled === true),
277
+ graphFallbackReasons: [
278
+ ...new Set(
279
+ metas.flatMap((meta) => meta.graphExpansion?.fallbackReasons ?? [])
280
+ ),
281
+ ].sort(compareCodeUnits),
282
+ });
283
+
284
+ const distributedLimit = (
285
+ total: number | undefined,
286
+ requestIndex: number,
287
+ requestCount: number
288
+ ): number | undefined => {
289
+ if (total === undefined) return undefined;
290
+ const base = Math.floor(total / requestCount);
291
+ return base + (requestIndex < total % requestCount ? 1 : 0);
292
+ };
293
+
294
+ /** Build a deterministic evidence plan; no answer generation occurs here. */
295
+ export const planContextEvidence = async <T, P>(
296
+ input: ContextCompilerInput,
297
+ deps: ContextCompilerDeps<T, P>
298
+ ): Promise<ContextEvidencePlan<T, P>> => {
299
+ const goal = normalizeContextText(input.goal);
300
+ const query = normalizeContextText(input.query ?? input.goal);
301
+ if (!goal || !query)
302
+ throw new Error("Context goal and query must be non-empty");
303
+ const now =
304
+ input.temporalNow instanceof Date
305
+ ? new Date(input.temporalNow)
306
+ : new Date(input.temporalNow);
307
+ if (Number.isNaN(now.getTime()))
308
+ throw new Error("Invalid frozen temporal now");
309
+ const observedAt =
310
+ input.observedAt === null ? null : new Date(input.observedAt).toISOString();
311
+ const queryModes = input.queryModes ?? [];
312
+ const facetPlan = deriveContextFacetPlan(query, queryModes);
313
+ const facets = facetPlan.map((facet) => facet.value);
314
+ const queryVariants = [
315
+ ...new Set([
316
+ query,
317
+ ...queryModes.map((mode) => normalizeContextText(mode.text)),
318
+ ]),
319
+ ];
320
+ const temporalRange = resolveTemporalRange(
321
+ query,
322
+ input.since,
323
+ input.until,
324
+ now
325
+ );
326
+ const collections = [...new Set(input.collections)].sort(compareCodeUnits);
327
+ const indexName = canonicalizeIndexName(input.indexName);
328
+ const collectionRequests = collections.length > 0 ? collections : [undefined];
329
+ const requestCount = collectionRequests.length;
330
+ const responses: SearchResults[] = [];
331
+ for (const [requestIndex, collection] of collectionRequests.entries()) {
332
+ const resultLimit = distributedLimit(
333
+ input.limit,
334
+ requestIndex,
335
+ requestCount
336
+ );
337
+ const rerankLimit = distributedLimit(
338
+ input.candidateLimit,
339
+ requestIndex,
340
+ requestCount
341
+ );
342
+ const hasRerankBudget = rerankLimit === undefined || rerankLimit > 0;
343
+ responses.push(
344
+ await deps.retrieve({
345
+ query,
346
+ collection,
347
+ noExpand: true,
348
+ queryModes,
349
+ tagsAll: input.tagsAll,
350
+ tagsAny: input.tagsAny,
351
+ categories: input.categories,
352
+ author: input.author,
353
+ lang: input.lang,
354
+ since: temporalRange.since,
355
+ until: temporalRange.until,
356
+ graph: hasRerankBudget ? input.graph : false,
357
+ noRerank: hasRerankBudget ? undefined : true,
358
+ limit: resultLimit === undefined ? undefined : Math.max(1, resultLimit),
359
+ candidateLimit:
360
+ rerankLimit === undefined ? undefined : Math.max(1, rerankLimit),
361
+ })
362
+ );
363
+ }
364
+ const decoratedResults = responses
365
+ .flatMap((response) => response.results)
366
+ .map((result) => ({
367
+ ...result,
368
+ uri: decorateUriForIndex(result.uri, indexName),
369
+ }));
370
+ const results = decoratedResults
371
+ .map((result, index) => ({
372
+ result,
373
+ retrievalRank: plannerMeta(result, index + 1).retrievalRank,
374
+ }))
375
+ .sort(
376
+ (left, right) =>
377
+ left.retrievalRank - right.retrievalRank ||
378
+ compareSearchResults(left.result, right.result)
379
+ )
380
+ .slice(0, input.limit ?? decoratedResults.length)
381
+ .map(({ result }) => result)
382
+ .sort(compareSearchResults);
383
+ const uriPrefix =
384
+ input.uriPrefix === null || input.uriPrefix === undefined
385
+ ? null
386
+ : decorateUriForIndex(input.uriPrefix, indexName);
387
+ const inScopeResults = results.filter((result) =>
388
+ isContextUriInScope(result.uri, indexName, collections, uriPrefix)
389
+ );
390
+ const guidance = resolveContextGuidance(
391
+ input.contextSnapshot,
392
+ inScopeResults,
393
+ indexName
394
+ );
395
+ const filteredFacetMatches = new Set<string>();
396
+ const initialOmissions: ContextOmission[] = [];
397
+ const materialized: MaterializedContextCandidate<T>[] = [];
398
+ const retrievalSources = new Set<FusionSource>();
399
+ const plannedCandidates: ContextRetrievalCandidate[] = [];
400
+ const referencesByCandidate: ContextCandidateReference[] = [];
401
+
402
+ for (const [index, result] of results.entries()) {
403
+ const meta = plannerMeta(result, index + 1);
404
+ for (const source of meta.sources) retrievalSources.add(source);
405
+ const retrievalReference = referenceFromResult(result);
406
+ if (!isContextUriInScope(result.uri, indexName, collections, uriPrefix)) {
407
+ for (const facet of facetPlan) {
408
+ if (
409
+ candidateMatchesContextFacet(
410
+ facet,
411
+ result,
412
+ result.snippet,
413
+ temporalRange
414
+ )
415
+ ) {
416
+ filteredFacetMatches.add(facet.value);
417
+ }
418
+ }
419
+ initialOmissions.push({
420
+ ...retrievalReference,
421
+ reason: "filtered_by_scope",
422
+ });
423
+ continue;
424
+ }
425
+ plannedCandidates.push({
426
+ result,
427
+ retrievalRank: meta.retrievalRank,
428
+ retrievalSources: [...meta.sources].sort(compareCodeUnits),
429
+ graphExpanded: meta.graphExpanded,
430
+ contextIds:
431
+ guidance.idsByResultIdentity.get(
432
+ contextGuidanceResultIdentity(result)
433
+ ) ?? [],
434
+ observedAt,
435
+ });
436
+ referencesByCandidate.push(retrievalReference);
437
+ }
438
+
439
+ const outcomes =
440
+ plannedCandidates.length === 0
441
+ ? []
442
+ : await deps.materializeCandidates(plannedCandidates);
443
+ if (outcomes.length !== plannedCandidates.length) {
444
+ throw new Error("Context materialization batch must align with candidates");
445
+ }
446
+ for (const [index, plannedCandidate] of plannedCandidates.entries()) {
447
+ const outcome = outcomes[index];
448
+ const retrievalReference = referencesByCandidate[index];
449
+ if (!outcome || !retrievalReference) {
450
+ throw new Error("Context materialization batch alignment failed");
451
+ }
452
+ const result = plannedCandidate.result;
453
+ if (
454
+ !hasSameSourceIdentity(
455
+ outcome.ok ? outcome.candidate : outcome.reference,
456
+ retrievalReference
457
+ )
458
+ ) {
459
+ throw new Error(`Materialized Context provenance drifted: ${result.uri}`);
460
+ }
461
+ if (!outcome.ok) {
462
+ initialOmissions.push({
463
+ ...outcome.reference,
464
+ reason: "invalid_coordinates",
465
+ });
466
+ continue;
467
+ }
468
+ const matchedFacets = facetPlan
469
+ .filter((facet) =>
470
+ candidateMatchesContextFacet(
471
+ facet,
472
+ result,
473
+ outcome.candidate.text,
474
+ temporalRange
475
+ )
476
+ )
477
+ .map((facet) => facet.value);
478
+ materialized.push(
479
+ normalizeMaterialized(
480
+ outcome.candidate,
481
+ matchedFacets,
482
+ plannedCandidate.retrievalRank
483
+ )
484
+ );
485
+ }
486
+
487
+ const retrieval: ContextRetrievalPlan = {
488
+ facets,
489
+ queryVariants,
490
+ retrievalSources: [...retrievalSources].sort(compareCodeUnits),
491
+ ...mergeMeta(responses.map((response) => response.meta)),
492
+ };
493
+ const baseDraft = {
494
+ goal,
495
+ query,
496
+ indexName,
497
+ collections,
498
+ uriPrefix,
499
+ limits: input.limits,
500
+ retrieval,
501
+ configuredContexts: guidance.contexts,
502
+ };
503
+ const selection = selectContextEvidence({
504
+ candidates: materialized,
505
+ requestedFacets: facets,
506
+ initialOmissions,
507
+ filteredFacetMatches,
508
+ limits: input.limits,
509
+ projectCanonical: (state) =>
510
+ deps.projectCanonical({ ...baseDraft, selection: state }),
511
+ });
512
+ return { ...baseDraft, ...selection };
513
+ };
@@ -0,0 +1,33 @@
1
+ /** Deterministic projection for untrusted evidence metadata. */
2
+
3
+ export const CONTEXT_EVIDENCE_METADATA_MAX_LENGTH = 2048;
4
+
5
+ const wellFormedScalar = (value: string): string => {
6
+ const codePoint = value.codePointAt(0);
7
+ return codePoint !== undefined && codePoint >= 0xd800 && codePoint <= 0xdfff
8
+ ? "\uFFFD"
9
+ : value;
10
+ };
11
+
12
+ /**
13
+ * Match the Capsule metadata normalization boundary, then truncate without
14
+ * splitting a Unicode scalar or retaining lone surrogate code units.
15
+ */
16
+ export const projectContextEvidenceMetadata = (
17
+ value: string | null
18
+ ): string | null => {
19
+ if (value === null) return null;
20
+ const normalized = value.replace(/\r\n?/g, "\n").normalize("NFC");
21
+ let projected = "";
22
+ for (const rawScalar of normalized) {
23
+ const scalar = wellFormedScalar(rawScalar);
24
+ if (
25
+ projected.length + scalar.length >
26
+ CONTEXT_EVIDENCE_METADATA_MAX_LENGTH
27
+ ) {
28
+ break;
29
+ }
30
+ projected += scalar;
31
+ }
32
+ return projected;
33
+ };