@hraness/kb 0.17.1

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 (200) hide show
  1. package/DISCLOSURE +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +545 -0
  4. package/dist/agent-context.js +35 -0
  5. package/dist/agent-guide-audit.js +18 -0
  6. package/dist/attachments.js +19 -0
  7. package/dist/authoring.js +33 -0
  8. package/dist/benchmark.js +19 -0
  9. package/dist/capture.js +49 -0
  10. package/dist/cli.js +3646 -0
  11. package/dist/clip/acquire.js +57 -0
  12. package/dist/clip/args.js +21 -0
  13. package/dist/clip/bounded-byte-buffer.js +9 -0
  14. package/dist/clip/browser-profiles.js +17 -0
  15. package/dist/clip/bundle-reader.js +11 -0
  16. package/dist/clip/cli.js +27 -0
  17. package/dist/clip/cookies.js +21 -0
  18. package/dist/clip/defuddle-worker.js +84 -0
  19. package/dist/clip/doctor.js +32 -0
  20. package/dist/clip/jobs.js +784 -0
  21. package/dist/clip/network-proxy.js +9 -0
  22. package/dist/clip/network.js +28 -0
  23. package/dist/clip/persist.js +30 -0
  24. package/dist/clip/refresh.js +11 -0
  25. package/dist/clip/terminal.js +11 -0
  26. package/dist/evaluation-builder.js +12238 -0
  27. package/dist/evaluation-kb.js +26 -0
  28. package/dist/evaluation.js +35 -0
  29. package/dist/git.js +39 -0
  30. package/dist/graph.js +39 -0
  31. package/dist/index-01jj6rbv.js +1103 -0
  32. package/dist/index-06c9ctr6.js +738 -0
  33. package/dist/index-0kavxzqj.js +2480 -0
  34. package/dist/index-1gwbassd.js +743 -0
  35. package/dist/index-1n418kb9.js +827 -0
  36. package/dist/index-1vrd1rmn.js +56 -0
  37. package/dist/index-1xxnjn0d.js +171 -0
  38. package/dist/index-3rm7cz6h.js +373 -0
  39. package/dist/index-3v2z4f0q.js +328 -0
  40. package/dist/index-48pz4jpc.js +392 -0
  41. package/dist/index-4j3tt0c3.js +234 -0
  42. package/dist/index-5n05se68.js +307 -0
  43. package/dist/index-5vwpzb5a.js +764 -0
  44. package/dist/index-6g2pv9d2.js +370 -0
  45. package/dist/index-7fzc32gf.js +712 -0
  46. package/dist/index-84x0vjjp.js +432 -0
  47. package/dist/index-adx6khj5.js +488 -0
  48. package/dist/index-b88v3vtm.js +805 -0
  49. package/dist/index-cv6fh7z5.js +462 -0
  50. package/dist/index-cxfrakt7.js +1067 -0
  51. package/dist/index-d13v9ckt.js +150 -0
  52. package/dist/index-dycjbhw2.js +1943 -0
  53. package/dist/index-dyqwejk5.js +531 -0
  54. package/dist/index-e5fbsywq.js +658 -0
  55. package/dist/index-f984hw45.js +927 -0
  56. package/dist/index-gh719d91.js +63 -0
  57. package/dist/index-hgve9rh2.js +2046 -0
  58. package/dist/index-hya40gb2.js +474 -0
  59. package/dist/index-j4zgmzjr.js +152 -0
  60. package/dist/index-jsmvyyvf.js +1166 -0
  61. package/dist/index-kh0pwvdd.js +1974 -0
  62. package/dist/index-mqx4nd6v.js +119 -0
  63. package/dist/index-mxxxytys.js +1254 -0
  64. package/dist/index-n5dd7r0v.js +711 -0
  65. package/dist/index-npg9z1a4.js +431 -0
  66. package/dist/index-pj501bh1.js +140 -0
  67. package/dist/index-qry4vhxk.js +1041 -0
  68. package/dist/index-s2gw5aw9.js +199 -0
  69. package/dist/index-vvdj6kca.js +70 -0
  70. package/dist/index-w2zc0vwa.js +571 -0
  71. package/dist/index-x3fthpsc.js +585 -0
  72. package/dist/index-zr53sf63.js +44 -0
  73. package/dist/index-zxdy5pby.js +2728 -0
  74. package/dist/index-zzhgcwyt.js +966 -0
  75. package/dist/index.js +453 -0
  76. package/dist/navigation.js +13 -0
  77. package/dist/pdf.js +85 -0
  78. package/dist/percolate.js +24 -0
  79. package/dist/portfolio.js +77 -0
  80. package/dist/query.js +29 -0
  81. package/dist/repository-memory.js +60 -0
  82. package/dist/sdk.js +36 -0
  83. package/dist/search-rules.js +34 -0
  84. package/dist/search.js +22 -0
  85. package/dist/semantic.js +39 -0
  86. package/dist/source-inbox.js +17 -0
  87. package/dist/untrusted-content.js +32 -0
  88. package/dist/url-intelligence.js +74 -0
  89. package/dist/workflow.js +23 -0
  90. package/dist/workflows/decision-context.js +20 -0
  91. package/dist/workflows/explain-change.js +8 -0
  92. package/dist/workflows/index.js +28 -0
  93. package/dist/workflows/plan-radar.js +10 -0
  94. package/package.json +409 -0
  95. package/skills/kb/AGENTS.md +19 -0
  96. package/skills/kb/SKILL.md +82 -0
  97. package/skills/kb/agents/openai.yaml +4 -0
  98. package/skills/kb/references/pdf-review.md +51 -0
  99. package/skills/kb/references/percolate.md +144 -0
  100. package/skills/kb/references/plan-structure.md +105 -0
  101. package/skills/kb/references/plan.md +115 -0
  102. package/skills/kb/references/query.md +231 -0
  103. package/skills/kb/references/refresh.md +133 -0
  104. package/skills/kb/references/save-pdf.md +125 -0
  105. package/skills/kb/references/save-url.md +178 -0
  106. package/skills/kb/references/url-authentication.md +75 -0
  107. package/skills/kb/references/url-platforms.md +25 -0
  108. package/src/agent-context.ts +1241 -0
  109. package/src/agent-guide-audit.ts +750 -0
  110. package/src/attachments.ts +723 -0
  111. package/src/authoring.ts +1725 -0
  112. package/src/benchmark.ts +362 -0
  113. package/src/capture.ts +68 -0
  114. package/src/cli.ts +3660 -0
  115. package/src/clip/acquire.ts +1273 -0
  116. package/src/clip/archive-today.ts +402 -0
  117. package/src/clip/args.ts +423 -0
  118. package/src/clip/assets.ts +262 -0
  119. package/src/clip/bounded-byte-buffer.ts +75 -0
  120. package/src/clip/browser-profiles.ts +372 -0
  121. package/src/clip/bundle-reader.ts +608 -0
  122. package/src/clip/capture.ts +1054 -0
  123. package/src/clip/cli.ts +181 -0
  124. package/src/clip/cookies.ts +512 -0
  125. package/src/clip/doctor.ts +1110 -0
  126. package/src/clip/extract.ts +868 -0
  127. package/src/clip/jobs.ts +959 -0
  128. package/src/clip/lib.ts +861 -0
  129. package/src/clip/media.ts +1474 -0
  130. package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
  131. package/src/clip/metadata-search-tool/Cargo.toml +18 -0
  132. package/src/clip/metadata-search-tool/runner.ts +685 -0
  133. package/src/clip/metadata-search-tool/src/main.rs +389 -0
  134. package/src/clip/metadata-search.ts +687 -0
  135. package/src/clip/network-proxy.ts +627 -0
  136. package/src/clip/network.ts +950 -0
  137. package/src/clip/package-root.ts +54 -0
  138. package/src/clip/persist.ts +1472 -0
  139. package/src/clip/platforms.ts +1824 -0
  140. package/src/clip/refresh.ts +197 -0
  141. package/src/clip/structured.ts +432 -0
  142. package/src/clip/terminal.ts +197 -0
  143. package/src/clip/url-intelligence.ts +581 -0
  144. package/src/clip/url-metadata-backfill.ts +616 -0
  145. package/src/clip/url-metadata-cli.ts +251 -0
  146. package/src/clip/url-metadata.ts +1397 -0
  147. package/src/evaluation-analysis-v2.ts +1440 -0
  148. package/src/evaluation-builder.ts +2539 -0
  149. package/src/evaluation-corpus-authoring.ts +2052 -0
  150. package/src/evaluation-evidence.ts +1147 -0
  151. package/src/evaluation-execution-v2.ts +426 -0
  152. package/src/evaluation-implementation.ts +131 -0
  153. package/src/evaluation-kb-closure.ts +1430 -0
  154. package/src/evaluation-kb-runner-v2.ts +735 -0
  155. package/src/evaluation-kb-v2.ts +2567 -0
  156. package/src/evaluation-kb.ts +1018 -0
  157. package/src/evaluation-measurement-v2.ts +1462 -0
  158. package/src/evaluation-packing-v2.ts +452 -0
  159. package/src/evaluation-redaction.ts +28 -0
  160. package/src/evaluation-v2.ts +3857 -0
  161. package/src/evaluation.ts +1220 -0
  162. package/src/git.ts +1276 -0
  163. package/src/graph.ts +1487 -0
  164. package/src/index.ts +20 -0
  165. package/src/init.ts +123 -0
  166. package/src/navigation.ts +277 -0
  167. package/src/note-lock.ts +529 -0
  168. package/src/pdf/args.ts +202 -0
  169. package/src/pdf/capture.ts +298 -0
  170. package/src/pdf/cli.ts +159 -0
  171. package/src/pdf/extract.ts +303 -0
  172. package/src/pdf/layout.ts +618 -0
  173. package/src/pdf/markdown.ts +154 -0
  174. package/src/pdf/model.ts +234 -0
  175. package/src/pdf/ocr.ts +190 -0
  176. package/src/pdf/persist.ts +314 -0
  177. package/src/pdf/source.ts +135 -0
  178. package/src/pdf/tools.ts +146 -0
  179. package/src/pdf.ts +92 -0
  180. package/src/percolate.ts +877 -0
  181. package/src/portfolio-audit.ts +574 -0
  182. package/src/portfolio-identity.ts +162 -0
  183. package/src/portfolio-registry.ts +473 -0
  184. package/src/portfolio.ts +765 -0
  185. package/src/query.ts +646 -0
  186. package/src/repository-memory.ts +1150 -0
  187. package/src/sdk.ts +1452 -0
  188. package/src/search-rules.ts +712 -0
  189. package/src/search.ts +710 -0
  190. package/src/semantic-runtime.ts +1139 -0
  191. package/src/semantic.ts +2480 -0
  192. package/src/source-inbox.ts +232 -0
  193. package/src/untrusted-content.ts +401 -0
  194. package/src/url-intelligence.ts +104 -0
  195. package/src/vault.ts +636 -0
  196. package/src/workflow.ts +653 -0
  197. package/src/workflows/decision-context.ts +81 -0
  198. package/src/workflows/explain-change.ts +73 -0
  199. package/src/workflows/index.ts +3 -0
  200. package/src/workflows/plan-radar.ts +101 -0
@@ -0,0 +1,712 @@
1
+ import type {
2
+ MetadataObject,
3
+ MetadataScalar,
4
+ MetadataValue,
5
+ } from "./graph.js";
6
+ import {
7
+ metadataAtPath,
8
+ validateQueryOptions,
9
+ type MetadataFilter,
10
+ type MetadataPath,
11
+ } from "./query.js";
12
+ import {
13
+ analyzeAuthoredRepositoryScopes,
14
+ validateRepositoryScopeSelection,
15
+ } from "./repository-memory.js";
16
+
17
+ export const SEARCH_RULES_SCHEMA_VERSION = 1;
18
+ export const MAX_SEARCH_RULE_ALIASES = 64;
19
+ export const MAX_SEARCH_PRIORITY_RULES = 128;
20
+ export const MAX_SEARCH_RULE_FILTERS = 64;
21
+ export const MAX_SEARCH_RULE_TAGS = 64;
22
+ export const MAX_SEARCH_RULE_SCOPES = 64;
23
+ export const MAX_SEARCH_RULE_TEXT_BYTES = 16 * 1_024;
24
+ export const MAX_SEARCH_RULE_CONFIG_BYTES = 128 * 1_024;
25
+ export const MAX_SEARCH_RULE_TIER = 32;
26
+
27
+ const MAX_SEARCH_RULE_NODES = 2_048;
28
+ const MAX_METADATA_PATH_SEGMENTS = 32;
29
+ const aliasNamePattern = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/u;
30
+ const ruleIdPattern = aliasNamePattern;
31
+ const vaultIdPattern = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?(?:\/[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?)?$/u;
32
+
33
+ export type SearchRuleMode = "exact" | "hybrid" | "keyword" | "semantic";
34
+
35
+ export type SearchAliasRule = {
36
+ readonly query?: string;
37
+ readonly mode?: SearchRuleMode;
38
+ readonly filters: readonly MetadataFilter[];
39
+ readonly tags: readonly string[];
40
+ readonly repositoryScopes: readonly string[];
41
+ };
42
+
43
+ export type SearchPriorityRule = {
44
+ readonly id: string;
45
+ /** Smaller positive tiers are preferred. */
46
+ readonly tier: number;
47
+ readonly pathPrefix?: string;
48
+ readonly tagsAll?: readonly string[];
49
+ readonly repositoryScope?: string;
50
+ readonly metadata?: readonly MetadataFilter[];
51
+ readonly vaultId?: string;
52
+ };
53
+
54
+ export type SearchRulesV1 = {
55
+ readonly schemaVersion: typeof SEARCH_RULES_SCHEMA_VERSION;
56
+ readonly aliases: Readonly<Record<string, SearchAliasRule>>;
57
+ readonly priorityRules: readonly SearchPriorityRule[];
58
+ };
59
+
60
+ export type SearchRuleRequest = {
61
+ readonly query: string;
62
+ readonly mode?: SearchRuleMode;
63
+ readonly filters?: readonly MetadataFilter[];
64
+ readonly tags?: readonly string[];
65
+ readonly repositoryScopes?: readonly string[];
66
+ };
67
+
68
+ type ReplacedSearchRuleRequestFields =
69
+ | "filters"
70
+ | "mode"
71
+ | "query"
72
+ | "repositoryScopes"
73
+ | "tags";
74
+
75
+ export type ExpandedSearchRuleRequest<T extends SearchRuleRequest> =
76
+ Omit<T, ReplacedSearchRuleRequestFields> & SearchRuleRequest;
77
+
78
+ export type SearchAliasExpansion<T extends SearchRuleRequest> = {
79
+ readonly request: ExpandedSearchRuleRequest<T>;
80
+ readonly alias: string | null;
81
+ };
82
+
83
+ export type SearchRuleHit = {
84
+ readonly id: string;
85
+ readonly path: string;
86
+ readonly identity: boolean;
87
+ readonly tags: readonly string[];
88
+ readonly metadata: MetadataObject;
89
+ /** Federated callers may attach the stable logical vault ID to each hit. */
90
+ readonly vaultId?: string;
91
+ /** Optional precomputed exact authored scopes; metadata remains the fallback. */
92
+ readonly repositoryScopes?: readonly string[];
93
+ };
94
+
95
+ export type SearchPriorityContext = {
96
+ /** Single-vault callers can supply the logical vault ID once. */
97
+ readonly vaultId?: string;
98
+ };
99
+
100
+ export type SearchPriorityTrace = {
101
+ readonly id: string;
102
+ /** One-based position in the caller-supplied relevance ordering. */
103
+ readonly relevanceRank: number;
104
+ readonly matchedRuleIds: readonly string[];
105
+ readonly tier: number | null;
106
+ };
107
+
108
+ export type PrioritizedSearchHits<T extends SearchRuleHit> = {
109
+ /** The original hit objects, reordered but never added, removed, or mutated. */
110
+ readonly hits: readonly T[];
111
+ /** Trace rows follow the returned hit order. */
112
+ readonly trace: readonly SearchPriorityTrace[];
113
+ };
114
+
115
+ type ParseBudget = {
116
+ bytes: number;
117
+ nodes: number;
118
+ };
119
+
120
+ export const EMPTY_SEARCH_RULES: SearchRulesV1 = Object.freeze({
121
+ schemaVersion: SEARCH_RULES_SCHEMA_VERSION,
122
+ aliases: Object.freeze({}),
123
+ priorityRules: Object.freeze([]),
124
+ });
125
+
126
+ function record(value: unknown, label: string): Readonly<Record<string, unknown>> {
127
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
128
+ throw new TypeError(`${label} must be an object.`);
129
+ }
130
+ const prototype = Object.getPrototypeOf(value) as unknown;
131
+ if (prototype !== Object.prototype && prototype !== null) {
132
+ throw new TypeError(`${label} must be a plain object.`);
133
+ }
134
+ return value as Readonly<Record<string, unknown>>;
135
+ }
136
+
137
+ function strictKeys(
138
+ value: Readonly<Record<string, unknown>>,
139
+ allowed: readonly string[],
140
+ label: string,
141
+ ): void {
142
+ const allowedKeys = new Set(allowed);
143
+ for (const key of Reflect.ownKeys(value)) {
144
+ if (typeof key !== "string" || !allowedKeys.has(key)) {
145
+ throw new TypeError(`${label} contains unsupported key ${String(key)}.`);
146
+ }
147
+ }
148
+ }
149
+
150
+ function countNode(budget: ParseBudget, count = 1): void {
151
+ budget.nodes += count;
152
+ if (budget.nodes > MAX_SEARCH_RULE_NODES) {
153
+ throw new RangeError(
154
+ `Search rules may contain at most ${MAX_SEARCH_RULE_NODES} structured entries.`,
155
+ );
156
+ }
157
+ }
158
+
159
+ function countText(budget: ParseBudget, value: string, label: string): void {
160
+ const bytes = Buffer.byteLength(value, "utf8");
161
+ if (bytes > MAX_SEARCH_RULE_TEXT_BYTES) {
162
+ throw new RangeError(
163
+ `${label} must be at most ${MAX_SEARCH_RULE_TEXT_BYTES.toLocaleString("en-US")} UTF-8 bytes.`,
164
+ );
165
+ }
166
+ budget.bytes += bytes;
167
+ if (budget.bytes > MAX_SEARCH_RULE_CONFIG_BYTES) {
168
+ throw new RangeError(
169
+ `Search rules may contain at most ${MAX_SEARCH_RULE_CONFIG_BYTES.toLocaleString("en-US")} UTF-8 bytes of text.`,
170
+ );
171
+ }
172
+ }
173
+
174
+ function boundedString(
175
+ value: unknown,
176
+ label: string,
177
+ budget: ParseBudget,
178
+ options: { readonly trim?: boolean; readonly pattern?: RegExp } = {},
179
+ ): string {
180
+ if (typeof value !== "string") throw new TypeError(`${label} must be a string.`);
181
+ const checked = options.trim === true ? value.trim() : value;
182
+ if (checked === "") throw new TypeError(`${label} must not be empty.`);
183
+ if (options.trim === true && checked !== value) {
184
+ throw new TypeError(`${label} must not contain surrounding whitespace.`);
185
+ }
186
+ if (options.pattern !== undefined && !options.pattern.test(checked)) {
187
+ throw new TypeError(`${label} is not canonical.`);
188
+ }
189
+ countText(budget, checked, label);
190
+ return checked;
191
+ }
192
+
193
+ function metadataScalar(
194
+ value: unknown,
195
+ label: string,
196
+ budget: ParseBudget,
197
+ ): MetadataScalar {
198
+ if (
199
+ value !== null
200
+ && typeof value !== "string"
201
+ && typeof value !== "number"
202
+ && typeof value !== "boolean"
203
+ ) {
204
+ throw new TypeError(`${label} must be a metadata scalar.`);
205
+ }
206
+ if (typeof value === "number" && !Number.isFinite(value)) {
207
+ throw new TypeError(`${label} must be a finite metadata number.`);
208
+ }
209
+ if (typeof value === "string") countText(budget, value, label);
210
+ countNode(budget);
211
+ return value;
212
+ }
213
+
214
+ function metadataPath(
215
+ value: unknown,
216
+ label: string,
217
+ budget: ParseBudget,
218
+ ): MetadataPath {
219
+ if (typeof value === "string") {
220
+ countText(budget, value, label);
221
+ return value;
222
+ }
223
+ if (!Array.isArray(value)) {
224
+ throw new TypeError(`${label} must be a string or an array of path segments.`);
225
+ }
226
+ if (value.length === 0 || value.length > MAX_METADATA_PATH_SEGMENTS) {
227
+ throw new RangeError(
228
+ `${label} must contain from 1 through ${MAX_METADATA_PATH_SEGMENTS} segments.`,
229
+ );
230
+ }
231
+ countNode(budget, value.length);
232
+ return Object.freeze(value.map((segment, index) => {
233
+ if (typeof segment !== "string") {
234
+ throw new TypeError(`${label} segment ${index + 1} must be a string.`);
235
+ }
236
+ countText(budget, segment, `${label} segment ${index + 1}`);
237
+ return segment;
238
+ }));
239
+ }
240
+
241
+ function parseFilter(
242
+ value: unknown,
243
+ label: string,
244
+ budget: ParseBudget,
245
+ ): MetadataFilter {
246
+ const input = record(value, label);
247
+ countNode(budget);
248
+ if (input.kind === "exists") {
249
+ strictKeys(input, ["kind", "path"], label);
250
+ return Object.freeze({
251
+ kind: "exists",
252
+ path: metadataPath(input.path, `${label} path`, budget),
253
+ });
254
+ }
255
+ if (input.kind === "equals") {
256
+ strictKeys(input, ["kind", "path", "value"], label);
257
+ return Object.freeze({
258
+ kind: "equals",
259
+ path: metadataPath(input.path, `${label} path`, budget),
260
+ value: metadataScalar(input.value, `${label} value`, budget),
261
+ });
262
+ }
263
+ if (input.kind === "one-of") {
264
+ strictKeys(input, ["kind", "path", "values"], label);
265
+ if (!Array.isArray(input.values)) {
266
+ throw new TypeError(`${label} values must be an array.`);
267
+ }
268
+ if (input.values.length === 0 || input.values.length > MAX_SEARCH_RULE_FILTERS) {
269
+ throw new RangeError(
270
+ `${label} values must contain from 1 through ${MAX_SEARCH_RULE_FILTERS} entries.`,
271
+ );
272
+ }
273
+ countNode(budget, input.values.length);
274
+ return Object.freeze({
275
+ kind: "one-of",
276
+ path: metadataPath(input.path, `${label} path`, budget),
277
+ values: Object.freeze(input.values.map((candidate, index) =>
278
+ metadataScalar(candidate, `${label} value ${index + 1}`, budget))),
279
+ });
280
+ }
281
+ throw new TypeError(`${label} must be an equals, exists, or one-of filter.`);
282
+ }
283
+
284
+ function parseFilters(
285
+ value: unknown,
286
+ label: string,
287
+ budget: ParseBudget,
288
+ ): readonly MetadataFilter[] {
289
+ if (value === undefined) return Object.freeze([]);
290
+ if (!Array.isArray(value)) throw new TypeError(`${label} must be an array.`);
291
+ if (value.length > MAX_SEARCH_RULE_FILTERS) {
292
+ throw new RangeError(`${label} may contain at most ${MAX_SEARCH_RULE_FILTERS} entries.`);
293
+ }
294
+ const filters = Object.freeze(value.map((filter, index) =>
295
+ parseFilter(filter, `${label} ${index + 1}`, budget)));
296
+ validateQueryOptions({ filters });
297
+ return filters;
298
+ }
299
+
300
+ function normalizedTag(value: string): string {
301
+ return value.trim().replace(/^#+/u, "").normalize("NFC").toLocaleLowerCase("en-US");
302
+ }
303
+
304
+ function parseTags(
305
+ value: unknown,
306
+ label: string,
307
+ budget: ParseBudget,
308
+ requireNonempty = false,
309
+ ): readonly string[] {
310
+ if (value === undefined) return Object.freeze([]);
311
+ if (!Array.isArray(value)) throw new TypeError(`${label} must be an array.`);
312
+ if (value.length > MAX_SEARCH_RULE_TAGS || (requireNonempty && value.length === 0)) {
313
+ const minimum = requireNonempty ? "from 1 through" : "at most";
314
+ throw new RangeError(`${label} may contain ${minimum} ${MAX_SEARCH_RULE_TAGS} entries.`);
315
+ }
316
+ const seen = new Set<string>();
317
+ const tags: string[] = [];
318
+ for (const [index, candidate] of value.entries()) {
319
+ if (typeof candidate !== "string") {
320
+ throw new TypeError(`${label} ${index + 1} must be a string.`);
321
+ }
322
+ countText(budget, candidate, `${label} ${index + 1}`);
323
+ countNode(budget);
324
+ const tag = normalizedTag(candidate);
325
+ if (tag === "") throw new TypeError(`${label} ${index + 1} must not be empty.`);
326
+ if (!seen.has(tag)) {
327
+ seen.add(tag);
328
+ tags.push(tag);
329
+ }
330
+ }
331
+ return Object.freeze(tags);
332
+ }
333
+
334
+ function parseScopes(
335
+ value: unknown,
336
+ label: string,
337
+ budget: ParseBudget,
338
+ ): readonly string[] {
339
+ if (value === undefined) return Object.freeze([]);
340
+ if (!Array.isArray(value)) throw new TypeError(`${label} must be an array.`);
341
+ if (value.length > MAX_SEARCH_RULE_SCOPES) {
342
+ throw new RangeError(`${label} may contain at most ${MAX_SEARCH_RULE_SCOPES} entries.`);
343
+ }
344
+ for (const [index, candidate] of value.entries()) {
345
+ if (typeof candidate !== "string") {
346
+ throw new TypeError(`${label} ${index + 1} must be a string.`);
347
+ }
348
+ countText(budget, candidate, `${label} ${index + 1}`);
349
+ countNode(budget);
350
+ }
351
+ return Object.freeze([...validateRepositoryScopeSelection(value)]);
352
+ }
353
+
354
+ function parseMode(value: unknown, label: string): SearchRuleMode | undefined {
355
+ if (value === undefined) return undefined;
356
+ if (value !== "exact" && value !== "hybrid" && value !== "keyword" && value !== "semantic") {
357
+ throw new TypeError(`${label} must be exact, hybrid, keyword, or semantic.`);
358
+ }
359
+ return value;
360
+ }
361
+
362
+ function parseAlias(
363
+ value: unknown,
364
+ name: string,
365
+ budget: ParseBudget,
366
+ ): SearchAliasRule {
367
+ const input = record(value, `Search alias @${name}`);
368
+ strictKeys(input, ["filters", "mode", "query", "repositoryScopes", "tags"], `Search alias @${name}`);
369
+ countNode(budget);
370
+ const query = input.query === undefined
371
+ ? undefined
372
+ : boundedString(input.query, `Search alias @${name} query`, budget, { trim: true });
373
+ const mode = parseMode(input.mode, `Search alias @${name} mode`);
374
+ const filters = parseFilters(input.filters, `Search alias @${name} filters`, budget);
375
+ const tags = parseTags(input.tags, `Search alias @${name} tags`, budget);
376
+ const repositoryScopes = parseScopes(
377
+ input.repositoryScopes,
378
+ `Search alias @${name} repository scopes`,
379
+ budget,
380
+ );
381
+ return Object.freeze({
382
+ ...(query === undefined ? {} : { query }),
383
+ ...(mode === undefined ? {} : { mode }),
384
+ filters,
385
+ tags,
386
+ repositoryScopes,
387
+ });
388
+ }
389
+
390
+ function pathPrefix(value: unknown, label: string, budget: ParseBudget): string {
391
+ const prefix = boundedString(value, label, budget, { trim: true });
392
+ if (
393
+ prefix.startsWith("/")
394
+ || prefix.includes("\\")
395
+ || prefix.includes("\0")
396
+ || prefix.includes("//")
397
+ || prefix.split("/").some((segment) => segment === "." || segment === "..")
398
+ ) {
399
+ throw new TypeError(`${label} must be a canonical repository-relative prefix.`);
400
+ }
401
+ return prefix;
402
+ }
403
+
404
+ function vaultId(value: unknown, label: string, budget: ParseBudget): string {
405
+ return boundedString(value, label, budget, { trim: true, pattern: vaultIdPattern });
406
+ }
407
+
408
+ function parsePriorityRule(
409
+ value: unknown,
410
+ index: number,
411
+ budget: ParseBudget,
412
+ ): SearchPriorityRule {
413
+ const label = `Search priority rule ${index + 1}`;
414
+ const input = record(value, label);
415
+ strictKeys(input, [
416
+ "id",
417
+ "metadata",
418
+ "pathPrefix",
419
+ "repositoryScope",
420
+ "tagsAll",
421
+ "tier",
422
+ "vaultId",
423
+ ], label);
424
+ countNode(budget);
425
+ const id = boundedString(input.id, `${label} ID`, budget, {
426
+ trim: true,
427
+ pattern: ruleIdPattern,
428
+ });
429
+ if (!Number.isSafeInteger(input.tier) || (input.tier as number) < 1 || (input.tier as number) > MAX_SEARCH_RULE_TIER) {
430
+ throw new RangeError(`${label} tier must be an integer from 1 through ${MAX_SEARCH_RULE_TIER}.`);
431
+ }
432
+ const checkedPathPrefix = input.pathPrefix === undefined
433
+ ? undefined
434
+ : pathPrefix(input.pathPrefix, `${label} path prefix`, budget);
435
+ const tagsAll = input.tagsAll === undefined
436
+ ? undefined
437
+ : parseTags(input.tagsAll, `${label} required tags`, budget, true);
438
+ let repositoryScope: string | undefined;
439
+ if (input.repositoryScope !== undefined) {
440
+ if (typeof input.repositoryScope !== "string") {
441
+ throw new TypeError(`${label} repository scope must be a string.`);
442
+ }
443
+ countText(budget, input.repositoryScope, `${label} repository scope`);
444
+ repositoryScope = validateRepositoryScopeSelection([input.repositoryScope])[0];
445
+ }
446
+ const metadata = input.metadata === undefined
447
+ ? undefined
448
+ : parseFilters(input.metadata, `${label} metadata filters`, budget);
449
+ if (metadata !== undefined && metadata.length === 0) {
450
+ throw new RangeError(`${label} metadata filters must not be empty when provided.`);
451
+ }
452
+ const checkedVaultId = input.vaultId === undefined
453
+ ? undefined
454
+ : vaultId(input.vaultId, `${label} vault ID`, budget);
455
+ if (
456
+ checkedPathPrefix === undefined
457
+ && tagsAll === undefined
458
+ && repositoryScope === undefined
459
+ && metadata === undefined
460
+ && checkedVaultId === undefined
461
+ ) {
462
+ throw new TypeError(`${label} must contain at least one match condition.`);
463
+ }
464
+ return Object.freeze({
465
+ id,
466
+ tier: input.tier as number,
467
+ ...(checkedPathPrefix === undefined ? {} : { pathPrefix: checkedPathPrefix }),
468
+ ...(tagsAll === undefined ? {} : { tagsAll }),
469
+ ...(repositoryScope === undefined ? {} : { repositoryScope }),
470
+ ...(metadata === undefined ? {} : { metadata }),
471
+ ...(checkedVaultId === undefined ? {} : { vaultId: checkedVaultId }),
472
+ });
473
+ }
474
+
475
+ /** Parse a bounded, strict JSON-compatible search-rules document. */
476
+ export function parseSearchRules(value: unknown): SearchRulesV1 {
477
+ const input = record(value, "Search rules");
478
+ strictKeys(input, ["aliases", "priorityRules", "schemaVersion"], "Search rules");
479
+ if (input.schemaVersion !== SEARCH_RULES_SCHEMA_VERSION) {
480
+ throw new TypeError(`Search rules schemaVersion must be ${SEARCH_RULES_SCHEMA_VERSION}.`);
481
+ }
482
+ const budget: ParseBudget = { bytes: 0, nodes: 1 };
483
+ const aliasInput = input.aliases === undefined
484
+ ? Object.freeze({})
485
+ : record(input.aliases, "Search rule aliases");
486
+ if (Reflect.ownKeys(aliasInput).some((key) => typeof key !== "string")) {
487
+ throw new TypeError("Search rule aliases may contain only string alias names.");
488
+ }
489
+ const aliasEntries = Object.entries(aliasInput);
490
+ if (aliasEntries.length > MAX_SEARCH_RULE_ALIASES) {
491
+ throw new RangeError(`Search rules may define at most ${MAX_SEARCH_RULE_ALIASES} aliases.`);
492
+ }
493
+ const aliases: Array<readonly [string, SearchAliasRule]> = [];
494
+ for (const [rawName, definition] of aliasEntries) {
495
+ const name = boundedString(rawName, "Search alias name", budget, {
496
+ trim: true,
497
+ pattern: aliasNamePattern,
498
+ });
499
+ aliases.push([name, parseAlias(definition, name, budget)]);
500
+ }
501
+ const rawPriorityRules = input.priorityRules ?? [];
502
+ if (!Array.isArray(rawPriorityRules)) {
503
+ throw new TypeError("Search priorityRules must be an array.");
504
+ }
505
+ if (rawPriorityRules.length > MAX_SEARCH_PRIORITY_RULES) {
506
+ throw new RangeError(
507
+ `Search rules may define at most ${MAX_SEARCH_PRIORITY_RULES} priority rules.`,
508
+ );
509
+ }
510
+ const priorityRules = Object.freeze(rawPriorityRules.map((rule, index) =>
511
+ parsePriorityRule(rule, index, budget)));
512
+ const ids = new Set<string>();
513
+ for (const rule of priorityRules) {
514
+ if (ids.has(rule.id)) throw new TypeError(`Search priority rule ID ${rule.id} is duplicated.`);
515
+ ids.add(rule.id);
516
+ }
517
+ return Object.freeze({
518
+ schemaVersion: SEARCH_RULES_SCHEMA_VERSION,
519
+ aliases: Object.freeze(Object.fromEntries(aliases)),
520
+ priorityRules,
521
+ });
522
+ }
523
+
524
+ function leadingAlias(query: string): { readonly name: string; readonly rest: string } | null {
525
+ const leading = query.trimStart();
526
+ if (!leading.startsWith("@")) return null;
527
+ const whitespace = leading.search(/\s/u);
528
+ const token = whitespace < 0 ? leading : leading.slice(0, whitespace);
529
+ const name = token.slice(1);
530
+ if (!aliasNamePattern.test(name)) {
531
+ throw new TypeError("A leading search alias must use canonical @alias syntax.");
532
+ }
533
+ const rest = leading.slice(token.length).trim();
534
+ if (rest.startsWith("@")) {
535
+ throw new TypeError("A search query may contain only one leading @alias.");
536
+ }
537
+ return { name, rest };
538
+ }
539
+
540
+ function callerArray<T>(value: readonly T[] | undefined, label: string): readonly T[] {
541
+ if (value === undefined) return [];
542
+ if (!Array.isArray(value)) throw new TypeError(`${label} must be an array.`);
543
+ return value;
544
+ }
545
+
546
+ /** Expand one configured leading alias without replacing any caller-authored constraint. */
547
+ export function expandSearchRequest<T extends SearchRuleRequest>(
548
+ request: T,
549
+ rules: SearchRulesV1,
550
+ ): SearchAliasExpansion<T> {
551
+ if (typeof request.query !== "string") throw new TypeError("Search query must be a string.");
552
+ if (Object.keys(rules.aliases).length === 0) {
553
+ return Object.freeze({
554
+ request: request as ExpandedSearchRuleRequest<T>,
555
+ alias: null,
556
+ });
557
+ }
558
+ const parsed = leadingAlias(request.query);
559
+ if (parsed === null) {
560
+ return Object.freeze({
561
+ request: request as ExpandedSearchRuleRequest<T>,
562
+ alias: null,
563
+ });
564
+ }
565
+ if (!Object.hasOwn(rules.aliases, parsed.name)) {
566
+ throw new TypeError(`Unknown search alias @${parsed.name}.`);
567
+ }
568
+ const alias = rules.aliases[parsed.name];
569
+ if (alias === undefined) throw new TypeError(`Unknown search alias @${parsed.name}.`);
570
+ const query = [alias.query, parsed.rest].filter((part): part is string =>
571
+ part !== undefined && part !== "").join(" ");
572
+ if (query === "") throw new TypeError(`Search alias @${parsed.name} must produce a nonempty query.`);
573
+ if (Buffer.byteLength(query, "utf8") > MAX_SEARCH_RULE_TEXT_BYTES) {
574
+ throw new RangeError(
575
+ `Expanded search query must be at most ${MAX_SEARCH_RULE_TEXT_BYTES.toLocaleString("en-US")} UTF-8 bytes.`,
576
+ );
577
+ }
578
+ const callerFilters = callerArray(request.filters, "Search request filters");
579
+ const callerTags = callerArray(request.tags, "Search request tags");
580
+ const callerScopes = callerArray(request.repositoryScopes, "Search request repository scopes");
581
+ // Repository-scope selections are OR groups in the existing search API. When
582
+ // both the caller and alias supply a group, preserve the caller group and add
583
+ // the alias group as an ANDed metadata filter instead of broadening either one.
584
+ const aliasScopeFilters: readonly MetadataFilter[] =
585
+ callerScopes.length > 0 && alias.repositoryScopes.length > 0
586
+ ? [Object.freeze({
587
+ kind: "one-of",
588
+ path: "repository_scopes",
589
+ values: alias.repositoryScopes,
590
+ })]
591
+ : [];
592
+ const expandedScopes = callerScopes.length > 0
593
+ ? callerScopes
594
+ : alias.repositoryScopes;
595
+ const mode = request.mode ?? alias.mode;
596
+ if (request.mode !== undefined) parseMode(request.mode, "Search request mode");
597
+ const expanded = Object.freeze({
598
+ ...request,
599
+ query,
600
+ ...(mode === undefined ? {} : { mode }),
601
+ ...(callerFilters.length === 0 && alias.filters.length === 0 && aliasScopeFilters.length === 0
602
+ ? {}
603
+ : { filters: Object.freeze([...callerFilters, ...alias.filters, ...aliasScopeFilters]) }),
604
+ ...(callerTags.length === 0 && alias.tags.length === 0
605
+ ? {}
606
+ : { tags: Object.freeze([...callerTags, ...alias.tags]) }),
607
+ ...(expandedScopes.length === 0
608
+ ? {}
609
+ : { repositoryScopes: Object.freeze([...expandedScopes]) }),
610
+ }) as ExpandedSearchRuleRequest<T>;
611
+ return Object.freeze({ request: expanded, alias: parsed.name });
612
+ }
613
+
614
+ function isMetadataObject(value: MetadataValue): value is MetadataObject {
615
+ return value !== null && typeof value === "object" && !Array.isArray(value);
616
+ }
617
+
618
+ function normalizedString(value: string): string {
619
+ return value.normalize("NFC").toLocaleLowerCase("en-US");
620
+ }
621
+
622
+ function scalarMatches(value: MetadataValue, expected: MetadataScalar): boolean {
623
+ if (Array.isArray(value)) return value.some((candidate) => scalarMatches(candidate, expected));
624
+ if (isMetadataObject(value)) return false;
625
+ if (typeof value === "string" && typeof expected === "string") {
626
+ return normalizedString(value) === normalizedString(expected);
627
+ }
628
+ return Object.is(value, expected);
629
+ }
630
+
631
+ function metadataMatches(metadata: MetadataObject, filter: MetadataFilter): boolean {
632
+ const lookup = metadataAtPath(metadata, filter.path);
633
+ if (filter.kind === "exists") return lookup.found;
634
+ if (!lookup.found) return false;
635
+ if (filter.kind === "one-of") {
636
+ return filter.values.some((expected) => scalarMatches(lookup.value, expected));
637
+ }
638
+ return scalarMatches(lookup.value, filter.value);
639
+ }
640
+
641
+ function scopeMatches(hit: SearchRuleHit, expected: string): boolean {
642
+ if (hit.repositoryScopes !== undefined) return hit.repositoryScopes.includes(expected);
643
+ const authored = analyzeAuthoredRepositoryScopes(hit.metadata);
644
+ return authored.present && authored.valid && authored.scopes.includes(expected);
645
+ }
646
+
647
+ function ruleMatches(
648
+ hit: SearchRuleHit,
649
+ rule: SearchPriorityRule,
650
+ inheritedVaultId: string | undefined,
651
+ ): boolean {
652
+ if (rule.pathPrefix !== undefined && !hit.path.startsWith(rule.pathPrefix)) return false;
653
+ if (rule.tagsAll !== undefined) {
654
+ const tags = new Set(hit.tags.map(normalizedTag));
655
+ if (!rule.tagsAll.every((tag) => tags.has(tag))) return false;
656
+ }
657
+ if (rule.repositoryScope !== undefined && !scopeMatches(hit, rule.repositoryScope)) return false;
658
+ if (rule.metadata !== undefined && !rule.metadata.every((filter) =>
659
+ metadataMatches(hit.metadata, filter))) return false;
660
+ if (rule.vaultId !== undefined && (hit.vaultId ?? inheritedVaultId) !== rule.vaultId) return false;
661
+ return true;
662
+ }
663
+
664
+ /**
665
+ * Stably apply configured priority tiers after relevance retrieval.
666
+ * Exact identity remains ahead of every priority tier; ties retain relevance order.
667
+ */
668
+ export function prioritizeSearchHits<T extends SearchRuleHit>(
669
+ hits: readonly T[],
670
+ rules: SearchRulesV1,
671
+ context: SearchPriorityContext = {},
672
+ ): PrioritizedSearchHits<T> {
673
+ if (rules.priorityRules.length === 0) {
674
+ return Object.freeze({
675
+ hits,
676
+ trace: Object.freeze(hits.map((hit, index) => Object.freeze({
677
+ id: hit.id,
678
+ relevanceRank: index + 1,
679
+ matchedRuleIds: Object.freeze([]),
680
+ tier: null,
681
+ }))),
682
+ });
683
+ }
684
+ const inheritedVaultId = context.vaultId === undefined
685
+ ? undefined
686
+ : vaultId(context.vaultId, "Search priority context vault ID", { bytes: 0, nodes: 0 });
687
+ const candidates = hits.map((hit, index) => {
688
+ const matched = rules.priorityRules.filter((rule) => ruleMatches(hit, rule, inheritedVaultId));
689
+ const tier = matched.length === 0
690
+ ? null
691
+ : Math.min(...matched.map((rule) => rule.tier));
692
+ return {
693
+ hit,
694
+ trace: Object.freeze({
695
+ id: hit.id,
696
+ relevanceRank: index + 1,
697
+ matchedRuleIds: Object.freeze(matched.map(({ id }) => id)),
698
+ tier,
699
+ }) satisfies SearchPriorityTrace,
700
+ };
701
+ });
702
+ const ordered = candidates.toSorted((left, right) =>
703
+ Number(right.hit.identity) - Number(left.hit.identity)
704
+ || (left.trace.tier ?? Number.POSITIVE_INFINITY)
705
+ - (right.trace.tier ?? Number.POSITIVE_INFINITY)
706
+ || left.trace.relevanceRank - right.trace.relevanceRank);
707
+ const unchanged = ordered.every((candidate, index) => candidate === candidates[index]);
708
+ return Object.freeze({
709
+ hits: unchanged ? hits : Object.freeze(ordered.map(({ hit }) => hit)),
710
+ trace: Object.freeze(ordered.map(({ trace }) => trace)),
711
+ });
712
+ }