@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,325 @@
1
+ /** Validation, projection, and fingerprint rules for Context runtime surfaces. */
2
+
3
+ import type { ContextCanonicalProjection } from "../core/context-budget";
4
+ import type { ContextCapsuleV1 } from "../core/context-capsule";
5
+ import type { ContextCapabilityState } from "../core/context-capsule-retrieval-schema";
6
+ import type { ContextCapsulePayloadV1 } from "../core/context-capsule-schema";
7
+ import type { ContextCanonicalPlanDraft } from "../core/context-compiler";
8
+ import type { ContextEvidenceValue } from "../core/context-evidence";
9
+ import type { NormalizedContextBuildInput } from "./context-runtime-input";
10
+ import type { ContextCapsuleRuntimeDeps } from "./context-runtime-types";
11
+
12
+ import {
13
+ ContextCapsuleContractError,
14
+ createContextCapsuleV1,
15
+ } from "../core/context-capsule";
16
+ import { sha256Text } from "../core/context-capsule-validation";
17
+ import {
18
+ fingerprintContextRows,
19
+ toContextCapsuleEvidence,
20
+ } from "../core/context-evidence";
21
+ import { canonicalVerifierJson } from "../core/context-verifier-canonical";
22
+ import { resolveModelUri } from "../llm/registry";
23
+
24
+ const fingerprint = (value: unknown): string =>
25
+ sha256Text(canonicalVerifierJson(value));
26
+
27
+ const configFingerprint = (deps: ContextCapsuleRuntimeDeps): string =>
28
+ fingerprint(deps.config);
29
+
30
+ const configuredContextFingerprint = (
31
+ deps: ContextCapsuleRuntimeDeps
32
+ ): string =>
33
+ fingerprintContextRows(
34
+ (deps.config.contexts ?? []).map((context) => ({
35
+ ...context,
36
+ syncedAt: "",
37
+ }))
38
+ );
39
+
40
+ const retrievalFingerprint = (
41
+ capsule: Pick<
42
+ ContextCapsuleV1,
43
+ "goal" | "query" | "scope" | "retrieval" | "capabilities"
44
+ >,
45
+ contextFingerprint: string
46
+ ): string =>
47
+ fingerprint({
48
+ capabilities: capsule.capabilities,
49
+ contextFingerprint,
50
+ goal: capsule.goal,
51
+ query: capsule.query,
52
+ retrieval: capsule.retrieval,
53
+ scope: capsule.scope,
54
+ });
55
+
56
+ const capabilityState = (
57
+ requested: boolean,
58
+ used: boolean,
59
+ unavailableReasons: string[],
60
+ usedReasons: string[] = []
61
+ ): ContextCapabilityState => ({
62
+ requested,
63
+ attempted: requested,
64
+ outcome: used ? "used" : requested ? "unavailable" : "not_requested",
65
+ fallbackReasons: requested ? (used ? usedReasons : unavailableReasons) : [],
66
+ });
67
+
68
+ const capsuleCapabilityStates = (
69
+ draft: ContextCanonicalPlanDraft<ContextEvidenceValue>,
70
+ input: NormalizedContextBuildInput
71
+ ) => ({
72
+ semanticSearch: capabilityState(
73
+ input.depthPolicy !== "fast",
74
+ draft.retrieval.semanticSearch,
75
+ ["embedding_unavailable"]
76
+ ),
77
+ reranking: capabilityState(
78
+ input.depthPolicy !== "fast",
79
+ draft.retrieval.reranked,
80
+ ["reranking_unavailable"]
81
+ ),
82
+ graphExpansion: capabilityState(
83
+ input.graph,
84
+ draft.retrieval.graphExpansion,
85
+ draft.retrieval.graphFallbackReasons.length > 0
86
+ ? draft.retrieval.graphFallbackReasons
87
+ : ["graph_unavailable"],
88
+ draft.retrieval.graphFallbackReasons
89
+ ),
90
+ });
91
+
92
+ const capsuleCapabilities = (
93
+ states: ReturnType<typeof capsuleCapabilityStates>,
94
+ exactTokens: boolean,
95
+ configuredContext: boolean
96
+ ) => ({
97
+ lexicalSearch: true as const,
98
+ semanticSearch: states.semanticSearch.outcome === "used",
99
+ reranking: states.reranking.outcome === "used",
100
+ graphExpansion: states.graphExpansion.outcome === "used",
101
+ exactTokenCount: exactTokens,
102
+ configuredContext,
103
+ egressPolicy: false,
104
+ });
105
+
106
+ const capsuleFallbacks = (
107
+ capabilities: ReturnType<typeof capsuleCapabilities>,
108
+ states: ReturnType<typeof capsuleCapabilityStates>
109
+ ): ContextCapsulePayloadV1["fallbacks"] => [
110
+ ...(states.semanticSearch.outcome === "unavailable"
111
+ ? [
112
+ {
113
+ code: "embedding_unavailable" as const,
114
+ capability: "semantic_search" as const,
115
+ },
116
+ ]
117
+ : []),
118
+ ...(states.reranking.outcome === "unavailable"
119
+ ? [
120
+ {
121
+ code: "reranking_unavailable" as const,
122
+ capability: "reranking" as const,
123
+ },
124
+ ]
125
+ : []),
126
+ ...(states.graphExpansion.outcome === "unavailable"
127
+ ? [
128
+ {
129
+ code: "graph_unavailable" as const,
130
+ capability: "graph_expansion" as const,
131
+ },
132
+ ]
133
+ : []),
134
+ ...(capabilities.exactTokenCount
135
+ ? []
136
+ : [
137
+ {
138
+ code: "tokenizer_unavailable" as const,
139
+ capability: "token_count" as const,
140
+ },
141
+ ]),
142
+ { code: "egress_policy_unavailable", capability: "egress_policy" },
143
+ ];
144
+
145
+ export const projectContextCapsule = (
146
+ draft: ContextCanonicalPlanDraft<ContextEvidenceValue>,
147
+ snapshots: { indexFingerprint: string; contextFingerprint: string },
148
+ input: NormalizedContextBuildInput,
149
+ deps: ContextCapsuleRuntimeDeps
150
+ ): ContextCanonicalProjection<ContextCapsuleV1> | null => {
151
+ if (draft.selection.selected.length === 0) return null;
152
+ const evidence = draft.selection.selected.map((candidate, index) =>
153
+ toContextCapsuleEvidence(candidate, index + 1)
154
+ );
155
+ const evidenceIdsByFacet = new Map<string, string[]>();
156
+ for (const item of evidence) {
157
+ for (const facet of item.facets) {
158
+ const ids = evidenceIdsByFacet.get(facet) ?? [];
159
+ ids.push(item.evidenceId);
160
+ evidenceIdsByFacet.set(facet, ids);
161
+ }
162
+ }
163
+ const exactTokens =
164
+ deps.countTokens !== undefined && deps.tokenizerFingerprint != null;
165
+ const capabilityStates = capsuleCapabilityStates(draft, input);
166
+ const capabilities = capsuleCapabilities(
167
+ capabilityStates,
168
+ exactTokens,
169
+ draft.configuredContexts.length > 0
170
+ );
171
+ const base = {
172
+ schemaVersion: "1.0" as const,
173
+ coordinateSpace: "canonical_mirror" as const,
174
+ goal: draft.goal,
175
+ query: draft.query,
176
+ scope: {
177
+ indexName: draft.indexName,
178
+ collections: draft.collections,
179
+ uriPrefix: draft.uriPrefix,
180
+ tagsAll: input.tagsAll,
181
+ tagsAny: input.tagsAny,
182
+ categories: input.categories,
183
+ since: input.since ?? null,
184
+ until: input.until ?? null,
185
+ },
186
+ retrieval: {
187
+ depthPolicy: input.depthPolicy,
188
+ facets: draft.retrieval.facets,
189
+ queryVariants: draft.retrieval.queryVariants,
190
+ expansionPolicy: "deterministic_only" as const,
191
+ request: {
192
+ author: input.author,
193
+ lang: input.lang,
194
+ queryModes: input.queryModes,
195
+ limit: input.limit,
196
+ candidateLimit: input.candidateLimit,
197
+ graphRequested: input.graph,
198
+ },
199
+ capabilityStates,
200
+ indexSnapshot: {
201
+ before: snapshots.indexFingerprint,
202
+ after: snapshots.indexFingerprint,
203
+ stable: true as const,
204
+ },
205
+ },
206
+ capabilities,
207
+ };
208
+ const payload: ContextCapsulePayloadV1 = {
209
+ ...base,
210
+ budget: {
211
+ authority: "canonical_json",
212
+ requestedTokens: input.budgetTokens,
213
+ requestedBytes: input.budgetBytes,
214
+ safetyMarginTokens: input.safetyMarginTokens,
215
+ safetyMarginBytes: input.safetyMarginBytes,
216
+ usedTokens: 1,
217
+ usedBytes: 0,
218
+ estimator: exactTokens ? "active_tokenizer" : "unicode_conservative",
219
+ tokenizerFingerprint: exactTokens
220
+ ? (deps.tokenizerFingerprint ?? null)
221
+ : null,
222
+ },
223
+ fingerprints: {
224
+ config: configFingerprint(deps),
225
+ retrieval: retrievalFingerprint(base, snapshots.contextFingerprint),
226
+ embeddingModel: capabilities.semanticSearch
227
+ ? sha256Text(deps.embedPort?.modelUri ?? "")
228
+ : null,
229
+ rerankModel: capabilities.reranking
230
+ ? sha256Text(deps.rerankPort?.modelUri ?? "")
231
+ : null,
232
+ tokenizer: exactTokens ? (deps.tokenizerFingerprint ?? null) : null,
233
+ },
234
+ fallbacks: capsuleFallbacks(capabilities, capabilityStates),
235
+ guidance: {
236
+ extractiveOnly: true,
237
+ evidenceTrust: "untrusted_data",
238
+ instructionBoundary: "hard_delimited",
239
+ configuredContexts: draft.configuredContexts,
240
+ },
241
+ evidence,
242
+ coverage: {
243
+ complete: draft.selection.coverage.unresolvedFacets.length === 0,
244
+ requestedFacets: draft.retrieval.facets,
245
+ coveredFacets: draft.selection.coverage.coveredFacets.map((facet) => ({
246
+ facet,
247
+ evidenceIds: evidenceIdsByFacet.get(facet) ?? [],
248
+ })),
249
+ unresolvedFacets: draft.selection.coverage.unresolvedFacets,
250
+ gaps: draft.selection.coverage.gaps,
251
+ },
252
+ omissions: {
253
+ total: draft.selection.omissions.length,
254
+ items: draft.selection.omissions.slice(0, 20),
255
+ reasonCounts: draft.selection.reasonCounts,
256
+ truncated: draft.selection.omissions.length > 20,
257
+ },
258
+ truncated: draft.selection.omissions.some(
259
+ (item) => item.reason === "global_budget"
260
+ ),
261
+ warnings: [
262
+ ...(draft.selection.coverage.unresolvedFacets.length > 0
263
+ ? [{ code: "incomplete_coverage" as const }]
264
+ : []),
265
+ ...(draft.selection.omissions.length > 20
266
+ ? [{ code: "omissions_truncated" as const }]
267
+ : []),
268
+ ...(exactTokens ? [] : [{ code: "token_estimate_used" as const }]),
269
+ ],
270
+ };
271
+ try {
272
+ const value = createContextCapsuleV1(payload, {
273
+ countTokens: deps.countTokens,
274
+ });
275
+ return {
276
+ value,
277
+ usedBytes: value.budget.usedBytes,
278
+ usedTokens: value.budget.usedTokens,
279
+ };
280
+ } catch (error) {
281
+ if (
282
+ error instanceof ContextCapsuleContractError &&
283
+ error.code === "invalid_budget"
284
+ ) {
285
+ return null;
286
+ }
287
+ throw error;
288
+ }
289
+ };
290
+
291
+ export const currentContextFingerprints = (
292
+ capsule: ContextCapsuleV1,
293
+ deps: ContextCapsuleRuntimeDeps
294
+ ) => ({
295
+ config: configFingerprint(deps),
296
+ retrieval: retrievalFingerprint(capsule, configuredContextFingerprint(deps)),
297
+ embeddingModel: capsule.capabilities.semanticSearch
298
+ ? sha256Text(
299
+ resolveModelUri(
300
+ deps.config,
301
+ "embed",
302
+ undefined,
303
+ capsule.scope.collections.length === 1
304
+ ? capsule.scope.collections[0]
305
+ : undefined
306
+ )
307
+ )
308
+ : null,
309
+ rerankModel: capsule.capabilities.reranking
310
+ ? sha256Text(
311
+ resolveModelUri(
312
+ deps.config,
313
+ "rerank",
314
+ undefined,
315
+ capsule.scope.collections.length === 1
316
+ ? capsule.scope.collections[0]
317
+ : undefined
318
+ )
319
+ )
320
+ : null,
321
+ tokenizer:
322
+ capsule.capabilities.exactTokenCount && deps.tokenizerFingerprint
323
+ ? deps.tokenizerFingerprint
324
+ : null,
325
+ });
@@ -0,0 +1,362 @@
1
+ /** Strict shared input normalization for every Context Capsule surface. */
2
+
3
+ import type { QueryModeInput } from "../pipeline/types";
4
+ import type { ContextCapsuleBuildInput } from "./context-runtime-types";
5
+
6
+ import { isValidLanguageHint } from "../config/types";
7
+ import { normalizeTag, validateTag } from "../core/tags";
8
+ import { resolveTemporalRange } from "../pipeline/temporal";
9
+ import { buildUri, parseUri } from "./constants";
10
+ import { ContextRuntimeError } from "./context-runtime-types";
11
+ import { canonicalizeIndexName } from "./index-name";
12
+
13
+ const COLLECTION_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
14
+ const QUERY_MODES = new Set(["term", "intent", "hyde"]);
15
+ const MAX_FILTER_VALUES = 128;
16
+ const MAX_FILTER_LENGTH = 256;
17
+ const MAX_TEXT_LENGTH = 16_384;
18
+ const DEFAULT_LIMIT = 20;
19
+ const DEFAULT_CANDIDATE_LIMIT = 40;
20
+
21
+ const compareCodeUnits = (left: string, right: string): number =>
22
+ left < right ? -1 : left > right ? 1 : 0;
23
+
24
+ const canonicalStrings = (
25
+ values: readonly string[] | undefined,
26
+ label: string
27
+ ): string[] => {
28
+ if (
29
+ values !== undefined &&
30
+ (!Array.isArray(values) ||
31
+ values.some((value) => typeof value !== "string"))
32
+ ) {
33
+ throw new ContextRuntimeError("invalid_filter", `${label} must be strings`);
34
+ }
35
+ return [
36
+ ...new Set((values ?? []).map((value) => value.normalize("NFC").trim())),
37
+ ].sort(compareCodeUnits);
38
+ };
39
+
40
+ const canonicalFilters = (
41
+ values: readonly string[] | undefined,
42
+ label: string
43
+ ): string[] => {
44
+ const normalized = canonicalStrings(values, label);
45
+ if (
46
+ normalized.length > MAX_FILTER_VALUES ||
47
+ normalized.some(
48
+ (value) =>
49
+ value.length === 0 ||
50
+ value.length > MAX_FILTER_LENGTH ||
51
+ value.includes("\r")
52
+ )
53
+ ) {
54
+ throw new ContextRuntimeError(
55
+ "invalid_filter",
56
+ `${label} contains an invalid value`
57
+ );
58
+ }
59
+ return normalized;
60
+ };
61
+
62
+ const canonicalTagFilters = (
63
+ values: readonly string[] | undefined,
64
+ label: string
65
+ ): string[] => {
66
+ if (
67
+ values !== undefined &&
68
+ (!Array.isArray(values) ||
69
+ values.some((value) => typeof value !== "string"))
70
+ ) {
71
+ throw new ContextRuntimeError("invalid_filter", `${label} must be strings`);
72
+ }
73
+ const normalized = [
74
+ ...new Set((values ?? []).map((value) => normalizeTag(value))),
75
+ ].sort(compareCodeUnits);
76
+ if (
77
+ normalized.length > MAX_FILTER_VALUES ||
78
+ normalized.some(
79
+ (value) =>
80
+ value.length > MAX_FILTER_LENGTH ||
81
+ value.includes("\r") ||
82
+ !validateTag(value)
83
+ )
84
+ ) {
85
+ throw new ContextRuntimeError(
86
+ "invalid_filter",
87
+ `${label} contains an invalid tag`
88
+ );
89
+ }
90
+ return normalized;
91
+ };
92
+
93
+ const positiveSafeInteger = (
94
+ value: number,
95
+ label: string,
96
+ code: "invalid_budget" | "invalid_filter" = "invalid_budget"
97
+ ): number => {
98
+ if (!Number.isSafeInteger(value) || value < 1) {
99
+ throw new ContextRuntimeError(
100
+ code,
101
+ `${label} must be a positive safe integer`
102
+ );
103
+ }
104
+ return value;
105
+ };
106
+
107
+ const canonicalQueryModes = (
108
+ values: QueryModeInput[] | undefined
109
+ ): QueryModeInput[] => {
110
+ if (values === undefined) return [];
111
+ if (!Array.isArray(values) || values.length > MAX_FILTER_VALUES) {
112
+ throw new ContextRuntimeError("invalid_filter", "Invalid query modes");
113
+ }
114
+ const normalized = values.map((value) => {
115
+ if (
116
+ value === null ||
117
+ typeof value !== "object" ||
118
+ !QUERY_MODES.has(value.mode) ||
119
+ typeof value.text !== "string"
120
+ ) {
121
+ throw new ContextRuntimeError("invalid_filter", "Invalid query mode");
122
+ }
123
+ const text = value.text.normalize("NFC").trim();
124
+ if (!text || text.length > 4096 || text.includes("\r")) {
125
+ throw new ContextRuntimeError(
126
+ "invalid_filter",
127
+ "Invalid query mode text"
128
+ );
129
+ }
130
+ return { mode: value.mode, text };
131
+ });
132
+ if (normalized.filter((value) => value.mode === "hyde").length > 1) {
133
+ throw new ContextRuntimeError(
134
+ "invalid_filter",
135
+ "Only one hyde query mode is allowed"
136
+ );
137
+ }
138
+ return normalized;
139
+ };
140
+
141
+ const validateUriPrefix = (
142
+ value: string | null | undefined,
143
+ indexName: string,
144
+ collections: readonly string[]
145
+ ): string | null => {
146
+ if (value === undefined || value === null) return null;
147
+ if (typeof value !== "string") {
148
+ throw new ContextRuntimeError("invalid_uri", "URI prefix must be a string");
149
+ }
150
+ const parsed = parseUri(value);
151
+ if (
152
+ !parsed ||
153
+ parsed.collection.length === 0 ||
154
+ (parsed.indexName !== undefined &&
155
+ canonicalizeIndexName(parsed.indexName) !== indexName) ||
156
+ (collections.length > 0 && !collections.includes(parsed.collection))
157
+ ) {
158
+ throw new ContextRuntimeError(
159
+ "invalid_uri",
160
+ "URI prefix must be a canonical GNO reference inside the requested index and collections"
161
+ );
162
+ }
163
+ if (buildUri(parsed.collection, parsed.path, { indexName }) !== value) {
164
+ throw new ContextRuntimeError(
165
+ "invalid_uri",
166
+ "URI prefix must use its canonical indexed GNO representation"
167
+ );
168
+ }
169
+ return value;
170
+ };
171
+
172
+ export const normalizeContextBuildInput = (
173
+ input: ContextCapsuleBuildInput,
174
+ defaultIndexName: string | undefined,
175
+ now: Date,
176
+ configuredCollectionNames?: readonly string[]
177
+ ) => {
178
+ if (!input || typeof input !== "object" || typeof input.goal !== "string") {
179
+ throw new ContextRuntimeError("invalid_goal", "Context goal is required");
180
+ }
181
+ const rawQuery = input.query ?? input.goal;
182
+ const goal = input.goal.normalize("NFC").trim();
183
+ const query =
184
+ typeof rawQuery === "string" ? rawQuery.normalize("NFC").trim() : "";
185
+ if (
186
+ !goal ||
187
+ !query ||
188
+ goal.length > MAX_TEXT_LENGTH ||
189
+ query.length > MAX_TEXT_LENGTH ||
190
+ goal.includes("\r") ||
191
+ query.includes("\r")
192
+ ) {
193
+ throw new ContextRuntimeError(
194
+ "invalid_goal",
195
+ "Context goal and query must be non-empty canonical text"
196
+ );
197
+ }
198
+ let indexName: string;
199
+ try {
200
+ indexName = canonicalizeIndexName(
201
+ input.indexName ?? defaultIndexName ?? "default"
202
+ );
203
+ } catch (cause) {
204
+ throw new ContextRuntimeError(
205
+ "invalid_filter",
206
+ "Context index name is invalid",
207
+ cause
208
+ );
209
+ }
210
+ const collections = canonicalStrings(input.collections, "collections");
211
+ if (
212
+ collections.length > MAX_FILTER_VALUES ||
213
+ collections.some((value) => !COLLECTION_PATTERN.test(value))
214
+ ) {
215
+ throw new ContextRuntimeError(
216
+ "invalid_filter",
217
+ "Invalid collection filter"
218
+ );
219
+ }
220
+ if (configuredCollectionNames) {
221
+ const configured = new Set(configuredCollectionNames);
222
+ const unknown = collections.find(
223
+ (collection) => !configured.has(collection)
224
+ );
225
+ if (unknown) {
226
+ throw new ContextRuntimeError(
227
+ "invalid_filter",
228
+ `Collection not found: ${unknown}`
229
+ );
230
+ }
231
+ const prefixCollection =
232
+ input.uriPrefix === undefined || input.uriPrefix === null
233
+ ? null
234
+ : parseUri(input.uriPrefix)?.collection;
235
+ if (prefixCollection && !configured.has(prefixCollection)) {
236
+ throw new ContextRuntimeError(
237
+ "invalid_filter",
238
+ `Collection not found: ${prefixCollection}`
239
+ );
240
+ }
241
+ }
242
+ const uriPrefix = validateUriPrefix(input.uriPrefix, indexName, collections);
243
+ const budgetTokens = positiveSafeInteger(
244
+ input.budgetTokens,
245
+ "Context token budget"
246
+ );
247
+ const budgetBytes =
248
+ input.budgetBytes === undefined
249
+ ? Math.min(Number.MAX_SAFE_INTEGER, budgetTokens * 4)
250
+ : positiveSafeInteger(input.budgetBytes, "Context byte budget");
251
+ const safetyMarginTokens = input.safetyMarginTokens ?? 0;
252
+ const safetyMarginBytes = input.safetyMarginBytes ?? 0;
253
+ if (
254
+ !Number.isSafeInteger(safetyMarginTokens) ||
255
+ !Number.isSafeInteger(safetyMarginBytes) ||
256
+ safetyMarginTokens < 0 ||
257
+ safetyMarginBytes < 0 ||
258
+ safetyMarginTokens >= budgetTokens ||
259
+ safetyMarginBytes >= budgetBytes
260
+ ) {
261
+ throw new ContextRuntimeError(
262
+ "invalid_budget",
263
+ "Context safety margins must be non-negative and smaller than their budgets"
264
+ );
265
+ }
266
+ if (
267
+ (input.since !== undefined && typeof input.since !== "string") ||
268
+ (input.until !== undefined && typeof input.until !== "string")
269
+ ) {
270
+ throw new ContextRuntimeError(
271
+ "invalid_filter",
272
+ "Context date filters are invalid"
273
+ );
274
+ }
275
+ const temporalRange = resolveTemporalRange(
276
+ query,
277
+ input.since,
278
+ input.until,
279
+ now
280
+ );
281
+ if (
282
+ (input.since !== undefined && temporalRange.since === undefined) ||
283
+ (input.until !== undefined && temporalRange.until === undefined) ||
284
+ (temporalRange.since !== undefined &&
285
+ temporalRange.until !== undefined &&
286
+ temporalRange.since > temporalRange.until)
287
+ ) {
288
+ throw new ContextRuntimeError(
289
+ "invalid_filter",
290
+ "Context date filters are invalid or reversed"
291
+ );
292
+ }
293
+ const depthPolicy = input.depthPolicy ?? "balanced";
294
+ if (!["fast", "balanced", "thorough"].includes(depthPolicy)) {
295
+ throw new ContextRuntimeError(
296
+ "invalid_filter",
297
+ "Invalid Context depth policy"
298
+ );
299
+ }
300
+ const limit = input.limit ?? DEFAULT_LIMIT;
301
+ const candidateLimit =
302
+ input.candidateLimit ??
303
+ (depthPolicy === "thorough"
304
+ ? DEFAULT_CANDIDATE_LIMIT * 2
305
+ : DEFAULT_CANDIDATE_LIMIT);
306
+ positiveSafeInteger(limit, "Context result limit", "invalid_filter");
307
+ positiveSafeInteger(
308
+ candidateLimit,
309
+ "Context candidate limit",
310
+ "invalid_filter"
311
+ );
312
+ if (input.graph !== undefined && typeof input.graph !== "boolean") {
313
+ throw new ContextRuntimeError(
314
+ "invalid_filter",
315
+ "Context graph flag must be boolean"
316
+ );
317
+ }
318
+ const author =
319
+ typeof input.author === "string"
320
+ ? input.author.normalize("NFC").trim()
321
+ : null;
322
+ const lang =
323
+ typeof input.lang === "string" ? input.lang.normalize("NFC").trim() : null;
324
+ if (
325
+ (input.author !== undefined &&
326
+ (!author || author.length > MAX_FILTER_LENGTH)) ||
327
+ (input.lang !== undefined && (!lang || !isValidLanguageHint(lang)))
328
+ ) {
329
+ throw new ContextRuntimeError(
330
+ "invalid_filter",
331
+ "Context author or language filter is invalid"
332
+ );
333
+ }
334
+ return {
335
+ ...input,
336
+ goal,
337
+ query,
338
+ indexName,
339
+ collections,
340
+ uriPrefix,
341
+ queryModes: canonicalQueryModes(input.queryModes),
342
+ tagsAll: canonicalTagFilters(input.tagsAll, "tagsAll"),
343
+ tagsAny: canonicalTagFilters(input.tagsAny, "tagsAny"),
344
+ categories: canonicalFilters(input.categories, "categories"),
345
+ author,
346
+ lang,
347
+ since: temporalRange.since,
348
+ until: temporalRange.until,
349
+ graph: input.graph ?? false,
350
+ limit,
351
+ candidateLimit,
352
+ budgetTokens,
353
+ budgetBytes,
354
+ safetyMarginTokens,
355
+ safetyMarginBytes,
356
+ depthPolicy,
357
+ };
358
+ };
359
+
360
+ export type NormalizedContextBuildInput = ReturnType<
361
+ typeof normalizeContextBuildInput
362
+ >;