@gmickel/gno 1.26.0 → 1.27.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 (54) hide show
  1. package/README.md +2 -1
  2. package/assets/skill/SKILL.md +8 -0
  3. package/browser-extension/artifacts/{gno-browser-clipper-v1.26.0.zip → gno-browser-clipper-v1.27.0.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.27.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +1 -1
  7. package/spec/cli.md +15 -2
  8. package/spec/evals-agentic.md +17 -0
  9. package/spec/mcp.md +25 -6
  10. package/spec/output-schemas/ask.schema.json +3 -0
  11. package/spec/output-schemas/query-diagnose.schema.json +61 -4
  12. package/spec/output-schemas/search-results.schema.json +87 -1
  13. package/spec/output-schemas/status.schema.json +24 -0
  14. package/spec/project-profile.schema.json +3 -1
  15. package/src/app/context-runtime-contract.ts +4 -1
  16. package/src/app/context-runtime-types.ts +2 -0
  17. package/src/app/context-runtime.ts +26 -0
  18. package/src/app/verified-ask.ts +6 -1
  19. package/src/cli/commands/ask.ts +8 -1
  20. package/src/cli/commands/query.ts +6 -3
  21. package/src/cli/commands/search.ts +6 -1
  22. package/src/cli/commands/status.ts +43 -7
  23. package/src/cli/program.ts +2 -0
  24. package/src/config/content-types.ts +82 -0
  25. package/src/config/index.ts +8 -0
  26. package/src/config/project-profile.ts +8 -1
  27. package/src/config/types.ts +11 -2
  28. package/src/core/context-compiler.ts +38 -1
  29. package/src/core/retrieval-replay-candidate.ts +6 -1
  30. package/src/ingestion/sync-options.ts +6 -2
  31. package/src/ingestion/sync.ts +21 -29
  32. package/src/ingestion/types.ts +1 -1
  33. package/src/mcp/tools/ask.ts +1 -0
  34. package/src/mcp/tools/index.ts +4 -0
  35. package/src/mcp/tools/query.ts +4 -2
  36. package/src/mcp/tools/search.ts +3 -0
  37. package/src/mcp/tools/status.ts +4 -0
  38. package/src/pipeline/content-type-boost.ts +264 -0
  39. package/src/pipeline/diagnose.ts +46 -19
  40. package/src/pipeline/explain.ts +15 -2
  41. package/src/pipeline/hybrid.ts +170 -74
  42. package/src/pipeline/rerank.ts +45 -15
  43. package/src/pipeline/search.ts +29 -11
  44. package/src/pipeline/types.ts +13 -4
  45. package/src/pipeline/vsearch.ts +30 -10
  46. package/src/sdk/client.ts +19 -3
  47. package/src/sdk/index.ts +1 -0
  48. package/src/sdk/types.ts +21 -5
  49. package/src/serve/routes/api.ts +17 -3
  50. package/src/serve/status-model.ts +2 -0
  51. package/src/serve/status.ts +4 -0
  52. package/src/store/sqlite/adapter.ts +3 -0
  53. package/src/store/types.ts +3 -2
  54. package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip.sha256 +0 -1
@@ -12,6 +12,7 @@ import type {
12
12
  } from "../../core/retrieval-trace-session";
13
13
  import type { SearchOptions, SearchResults } from "../../pipeline/types";
14
14
 
15
+ import { normalizeContentTypes } from "../../config";
15
16
  import { resolveCliProjectAffinity } from "../../core/project-affinity-surface";
16
17
  import {
17
18
  finishRetrievalTraceAfterError,
@@ -28,7 +29,10 @@ import { decorateSearchResultsForIndex, initStore } from "./shared";
28
29
  // Types
29
30
  // ─────────────────────────────────────────────────────────────────────────────
30
31
 
31
- export type SearchCommandOptions = Omit<SearchOptions, "projectAffinity"> &
32
+ export type SearchCommandOptions = Omit<
33
+ SearchOptions,
34
+ "contentTypeRules" | "projectAffinity"
35
+ > &
32
36
  CliProjectAffinityRequest & {
33
37
  /** Override config path */
34
38
  configPath?: string;
@@ -121,6 +125,7 @@ export async function search(
121
125
  ...searchOptions,
122
126
  limit,
123
127
  projectAffinity,
128
+ contentTypeRules: normalizeContentTypes(config.contentTypes ?? []).rules,
124
129
  traceSession,
125
130
  });
126
131
 
@@ -5,11 +5,17 @@
5
5
  * @module src/cli/commands/status
6
6
  */
7
7
 
8
+ import type { ContentTypeBoostStatus } from "../../config/content-types";
8
9
  import type { ActivationStatus } from "../../core/activation-status";
9
10
  import type { IndexStatus } from "../../store/types";
10
11
 
11
12
  import { getIndexDbPath, getModelsCachePath } from "../../app/constants";
12
- import { getConfigPaths, isInitialized, loadConfig } from "../../config";
13
+ import {
14
+ buildContentTypeBoostStatus,
15
+ getConfigPaths,
16
+ isInitialized,
17
+ loadConfig,
18
+ } from "../../config";
13
19
  import { isConnectorActivationComplete } from "../../core/activation-connector-health";
14
20
  import { buildActivationStatus } from "../../core/activation-status";
15
21
  import { ModelCache } from "../../llm/cache";
@@ -36,7 +42,12 @@ export interface StatusOptions {
36
42
  * Result of status command.
37
43
  */
38
44
  export type StatusResult =
39
- | { success: true; status: IndexStatus; activation: ActivationStatus }
45
+ | {
46
+ success: true;
47
+ status: IndexStatus;
48
+ activation: ActivationStatus;
49
+ contentTypeBoost: ContentTypeBoostStatus;
50
+ }
40
51
  | { success: false; error: string };
41
52
 
42
53
  function connectorProjectionLine(activation: ActivationStatus): string | null {
@@ -63,7 +74,8 @@ function isStatusHealthy(
63
74
  */
64
75
  function formatTerminal(
65
76
  indexStatus: IndexStatus,
66
- activation: ActivationStatus
77
+ activation: ActivationStatus,
78
+ contentTypeBoost: ContentTypeBoostStatus
67
79
  ): string {
68
80
  const lines: string[] = [];
69
81
 
@@ -101,6 +113,11 @@ function formatTerminal(
101
113
  lines.push(`Last updated: ${indexStatus.lastUpdatedAt}`);
102
114
  }
103
115
 
116
+ lines.push(
117
+ `Content-type boosts: ${contentTypeBoost.rules.length ? contentTypeBoost.rules.map((rule) => `${rule.id}=${rule.searchBoost}`).join(", ") : "none"}`
118
+ );
119
+ lines.push(`Ranking fingerprint: ${contentTypeBoost.rulesFingerprint}`);
120
+
104
121
  lines.push(
105
122
  `Health: ${isStatusHealthy(indexStatus, activation) ? "OK" : "DEGRADED"}`
106
123
  );
@@ -138,7 +155,8 @@ function formatTerminal(
138
155
  */
139
156
  function formatMarkdown(
140
157
  indexStatus: IndexStatus,
141
- activation: ActivationStatus
158
+ activation: ActivationStatus,
159
+ contentTypeBoost: ContentTypeBoostStatus
142
160
  ): string {
143
161
  const lines: string[] = [];
144
162
 
@@ -174,6 +192,10 @@ function formatMarkdown(
174
192
  if (indexStatus.lastUpdatedAt) {
175
193
  lines.push(`- **Last updated**: ${indexStatus.lastUpdatedAt}`);
176
194
  }
195
+ lines.push(
196
+ `- **Content-type boosts**: ${contentTypeBoost.rules.length ? contentTypeBoost.rules.map((rule) => `${rule.id}=${rule.searchBoost}`).join(", ") : "none"}`
197
+ );
198
+ lines.push(`- **Ranking fingerprint**: ${contentTypeBoost.rulesFingerprint}`);
177
199
 
178
200
  lines.push("");
179
201
  lines.push("## Lexical activation");
@@ -254,7 +276,12 @@ export async function status(
254
276
  }
255
277
  );
256
278
 
257
- return { success: true, status: statusResult.value, activation };
279
+ return {
280
+ success: true,
281
+ status: statusResult.value,
282
+ activation,
283
+ contentTypeBoost: buildContentTypeBoostStatus(config.contentTypes ?? []),
284
+ };
258
285
  } finally {
259
286
  await store.close();
260
287
  }
@@ -294,6 +321,7 @@ export function formatStatus(
294
321
  embeddingBacklog: s.embeddingBacklog,
295
322
  lastUpdated: s.lastUpdatedAt,
296
323
  healthy: isStatusHealthy(s, result.activation),
324
+ contentTypeBoost: result.contentTypeBoost,
297
325
  activation: result.activation,
298
326
  },
299
327
  null,
@@ -302,8 +330,16 @@ export function formatStatus(
302
330
  }
303
331
 
304
332
  if (options.md) {
305
- return formatMarkdown(result.status, result.activation);
333
+ return formatMarkdown(
334
+ result.status,
335
+ result.activation,
336
+ result.contentTypeBoost
337
+ );
306
338
  }
307
339
 
308
- return formatTerminal(result.status, result.activation);
340
+ return formatTerminal(
341
+ result.status,
342
+ result.activation,
343
+ result.contentTypeBoost
344
+ );
309
345
  }
@@ -1151,6 +1151,7 @@ function wireSearchCommands(program: Command): void {
1151
1151
  .option("--context-budget-bytes <num>", "verified Context byte budget")
1152
1152
  .option("--min-score <score>", "minimum retrieval score (0-1)")
1153
1153
  .option("--graph", "include bounded graph expansion")
1154
+ .option("--explain", "include retrieval scoring explanation")
1154
1155
  .option(
1155
1156
  "--project-root <path>",
1156
1157
  "trusted project root (repeatable; replaces cwd affinity)",
@@ -1259,6 +1260,7 @@ function wireSearchCommands(program: Command): void {
1259
1260
  noExpand: depthPolicy.noExpand,
1260
1261
  noRerank: depthPolicy.noRerank,
1261
1262
  candidateLimit: depthPolicy.candidateLimit,
1263
+ explain: Boolean(cmdOpts.explain),
1262
1264
  // Per spec: --answer defaults to false, --no-answer forces retrieval-only
1263
1265
  // Commander creates separate cmdOpts.noAnswer for --no-answer flag
1264
1266
  answer: Boolean(cmdOpts.answer),
@@ -8,6 +8,7 @@ import type { NotePresetId } from "../core/note-presets";
8
8
  import type { Config, ContentTypeConfig } from "./types";
9
9
 
10
10
  import { NOTE_PRESETS } from "../core/note-presets";
11
+ import { CONTENT_TYPE_SEARCH_BOOST_NEUTRAL } from "./types";
11
12
 
12
13
  export type ConfigWarningCode =
13
14
  | "UNKNOWN_CONTENT_TYPE_PRESET"
@@ -21,6 +22,7 @@ export interface ConfigWarning {
21
22
 
22
23
  export interface NormalizedContentTypeRule extends ContentTypeConfig {
23
24
  preset: NotePresetId;
25
+ searchBoost: number;
24
26
  }
25
27
 
26
28
  export interface ContentTypeNormalizationResult {
@@ -33,6 +35,14 @@ export interface ConfigNormalizationResult {
33
35
  warnings: ConfigWarning[];
34
36
  }
35
37
 
38
+ export interface ContentTypeBoostStatus {
39
+ rulesFingerprint: string;
40
+ rules: Array<{
41
+ id: string;
42
+ searchBoost: number;
43
+ }>;
44
+ }
45
+
36
46
  const NOTE_PRESET_IDS = new Set<NotePresetId>(
37
47
  NOTE_PRESETS.map((preset) => preset.id)
38
48
  );
@@ -48,6 +58,10 @@ function isNotePresetId(value: string): value is NotePresetId {
48
58
  return NOTE_PRESET_IDS.has(value as NotePresetId);
49
59
  }
50
60
 
61
+ function normalizeSearchBoost(value: number | undefined): number {
62
+ return value ?? CONTENT_TYPE_SEARCH_BOOST_NEUTRAL;
63
+ }
64
+
51
65
  export function normalizeContentTypes(
52
66
  contentTypes: ContentTypeConfig[]
53
67
  ): ContentTypeNormalizationResult {
@@ -89,6 +103,7 @@ export function normalizeContentTypes(
89
103
  graphHints: contentType.graphHints
90
104
  ? contentType.graphHints.map(normalizeGraphHint).filter(Boolean)
91
105
  : undefined,
106
+ searchBoost: normalizeSearchBoost(contentType.searchBoost),
92
107
  });
93
108
  continue;
94
109
  }
@@ -100,6 +115,7 @@ export function normalizeContentTypes(
100
115
  graphHints: contentType.graphHints
101
116
  ? contentType.graphHints.map(normalizeGraphHint).filter(Boolean)
102
117
  : undefined,
118
+ searchBoost: normalizeSearchBoost(contentType.searchBoost),
103
119
  });
104
120
  }
105
121
 
@@ -112,6 +128,34 @@ export function normalizeContentTypes(
112
128
  return { rules, warnings };
113
129
  }
114
130
 
131
+ export interface ContentTypeRuleResolution {
132
+ rule: NormalizedContentTypeRule;
133
+ source: "configured-id" | "prefix";
134
+ }
135
+
136
+ /**
137
+ * Resolve one canonical configured rule. A configured frontmatter type wins;
138
+ * otherwise normalized rule order provides longest-prefix matching. Arbitrary
139
+ * category text is intentionally not an input and boosts never stack.
140
+ */
141
+ export function resolveContentTypeRule(
142
+ configuredId: string | undefined,
143
+ relativePath: string,
144
+ rules: NormalizedContentTypeRule[]
145
+ ): ContentTypeRuleResolution | undefined {
146
+ if (configuredId) {
147
+ const configuredRule = rules.find((rule) => rule.id === configuredId);
148
+ if (configuredRule) {
149
+ return { rule: configuredRule, source: "configured-id" };
150
+ }
151
+ }
152
+
153
+ const prefixRule = rules.find((rule) =>
154
+ rule.prefixes.some((prefix) => relativePath.startsWith(prefix))
155
+ );
156
+ return prefixRule ? { rule: prefixRule, source: "prefix" } : undefined;
157
+ }
158
+
115
159
  export function normalizeConfigContentTypes(
116
160
  config: Config
117
161
  ): ConfigNormalizationResult {
@@ -127,6 +171,44 @@ export function normalizeConfigContentTypes(
127
171
 
128
172
  export function fingerprintContentTypeRules(
129
173
  rules: NormalizedContentTypeRule[]
174
+ ): string {
175
+ const canonical = rules.map((rule) => {
176
+ const base = {
177
+ id: rule.id,
178
+ preset: rule.preset,
179
+ prefixes: rule.prefixes,
180
+ graphHints: rule.graphHints ?? [],
181
+ };
182
+ return rule.searchBoost === CONTENT_TYPE_SEARCH_BOOST_NEUTRAL
183
+ ? base
184
+ : { ...base, searchBoost: rule.searchBoost };
185
+ });
186
+ const hasher = new Bun.CryptoHasher("sha256");
187
+ hasher.update(JSON.stringify(canonical));
188
+ return hasher.digest("hex");
189
+ }
190
+
191
+ /**
192
+ * Redacted live-ranking projection for status surfaces. Prefixes remain local
193
+ * configuration details; rule IDs and factors are enough to audit behavior.
194
+ */
195
+ export function buildContentTypeBoostStatus(
196
+ contentTypes: ContentTypeConfig[]
197
+ ): ContentTypeBoostStatus {
198
+ const { rules } = normalizeContentTypes(contentTypes);
199
+ return {
200
+ rulesFingerprint: fingerprintContentTypeRules(rules),
201
+ rules: rules.map(({ id, searchBoost }) => ({ id, searchBoost })),
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Fingerprint only fields that derive persisted document metadata.
207
+ * Search boosts are evaluated from live config at query time, so changing one
208
+ * must not force content conversion or vector rebuilds.
209
+ */
210
+ export function fingerprintContentTypeMetadataRules(
211
+ rules: NormalizedContentTypeRule[]
130
212
  ): string {
131
213
  const canonical = rules.map((rule) => ({
132
214
  id: rule.id,
@@ -6,13 +6,18 @@
6
6
 
7
7
  export { createDefaultConfig } from "./defaults";
8
8
  export {
9
+ type ContentTypeRuleResolution,
10
+ type ContentTypeBoostStatus,
9
11
  type ConfigWarning,
12
+ buildContentTypeBoostStatus,
13
+ fingerprintContentTypeMetadataRules,
10
14
  fingerprintContentTypeRules,
11
15
  formatConfigWarning,
12
16
  formatConfigWarnings,
13
17
  normalizeConfigContentTypes,
14
18
  normalizeContentTypes,
15
19
  type NormalizedContentTypeRule,
20
+ resolveContentTypeRule,
16
21
  writeConfigWarningsToStderr,
17
22
  } from "./content-types";
18
23
  // Loading
@@ -47,6 +52,9 @@ export {
47
52
  // Types and schemas
48
53
  export {
49
54
  CONFIG_VERSION,
55
+ CONTENT_TYPE_SEARCH_BOOST_MAX,
56
+ CONTENT_TYPE_SEARCH_BOOST_MIN,
57
+ CONTENT_TYPE_SEARCH_BOOST_NEUTRAL,
50
58
  type Collection,
51
59
  CollectionSchema,
52
60
  type Config,
@@ -3,6 +3,8 @@ import { z } from "zod";
3
3
  import { NOTE_PRESETS } from "../core/note-presets";
4
4
  import { hasLikelySecretPath } from "../core/path-rules";
5
5
  import {
6
+ CONTENT_TYPE_SEARCH_BOOST_MAX,
7
+ CONTENT_TYPE_SEARCH_BOOST_MIN,
6
8
  isValidLanguageHint,
7
9
  PROJECT_AFFINITY_MAX_CONTRIBUTION,
8
10
  } from "./types";
@@ -284,7 +286,12 @@ export const ProjectProfileContentTypeSchema = z
284
286
  }
285
287
  })
286
288
  .optional(),
287
- searchBoost: z.number().finite().optional(),
289
+ searchBoost: z
290
+ .number()
291
+ .finite()
292
+ .min(CONTENT_TYPE_SEARCH_BOOST_MIN)
293
+ .max(CONTENT_TYPE_SEARCH_BOOST_MAX)
294
+ .optional(),
288
295
  temporal: z.boolean().optional(),
289
296
  })
290
297
  .strict();
@@ -385,6 +385,10 @@ export const CONTENT_TYPE_GRAPH_HINTS = [
385
385
 
386
386
  export type ContentTypeGraphHint = (typeof CONTENT_TYPE_GRAPH_HINTS)[number];
387
387
 
388
+ export const CONTENT_TYPE_SEARCH_BOOST_MIN = 0.5;
389
+ export const CONTENT_TYPE_SEARCH_BOOST_NEUTRAL = 1;
390
+ export const CONTENT_TYPE_SEARCH_BOOST_MAX = 2;
391
+
388
392
  export const ContentTypeSchema = z.object({
389
393
  /** Stable content type identifier */
390
394
  id: z.string().min(1),
@@ -394,8 +398,13 @@ export const ContentTypeSchema = z.object({
394
398
  preset: z.string().min(1),
395
399
  /** Reserved for fn-84 typed graph hints; accepted but no-op in fn-83 */
396
400
  graphHints: z.array(z.string().min(1)).optional(),
397
- /** Reserved for future ranking; accepted but no-op in fn-83 */
398
- searchBoost: z.number().finite().optional(),
401
+ /** Bounded soft ranking factor; normalized to 1.0 when omitted */
402
+ searchBoost: z
403
+ .number()
404
+ .finite()
405
+ .min(CONTENT_TYPE_SEARCH_BOOST_MIN)
406
+ .max(CONTENT_TYPE_SEARCH_BOOST_MAX)
407
+ .optional(),
399
408
  /** Marks time-oriented content types; accepted but no-op in fn-83 */
400
409
  temporal: z.boolean().optional(),
401
410
  });
@@ -11,6 +11,7 @@ import type {
11
11
  HybridSearchOptions,
12
12
  QueryModeInput,
13
13
  SearchMeta,
14
+ SearchExplain,
14
15
  SearchResult,
15
16
  SearchResults,
16
17
  } from "../pipeline/types";
@@ -113,6 +114,8 @@ export interface ContextCompilerInput {
113
114
  limits: ContextBudgetLimits;
114
115
  /** One caller-owned, successfully loaded context snapshot for this plan. */
115
116
  contextSnapshot: ContextRow[];
117
+ /** Internal non-canonical retrieval explanation request. */
118
+ explain?: boolean;
116
119
  }
117
120
 
118
121
  export interface ContextRetrievalPlan {
@@ -159,6 +162,8 @@ export interface ContextEvidencePlan<
159
162
  uriPrefix: string | null;
160
163
  retrieval: ContextRetrievalPlan;
161
164
  configuredContexts: ContextConfiguredGuidance[];
165
+ /** Non-canonical scoring sidecar; never included in Capsule projection. */
166
+ explain?: SearchExplain;
162
167
  }
163
168
 
164
169
  const compareCodeUnits = (left: string, right: string): number => {
@@ -365,6 +370,7 @@ export const planContextEvidence = async <T, P>(
365
370
  limit: resultLimit === undefined ? undefined : Math.max(1, resultLimit),
366
371
  candidateLimit:
367
372
  rerankLimit === undefined ? undefined : Math.max(1, rerankLimit),
373
+ explain: input.explain,
368
374
  })
369
375
  );
370
376
  }
@@ -393,6 +399,33 @@ export const planContextEvidence = async <T, P>(
393
399
  .slice(0, input.limit ?? decoratedResults.length)
394
400
  .map(({ result }) => result)
395
401
  .sort(compareSearchResults);
402
+ const explainByResult = new Map<string, SearchExplain["results"][number]>();
403
+ for (const response of responses) {
404
+ for (const [index, result] of response.results.entries()) {
405
+ const detail = response.meta.explain?.results[index];
406
+ if (detail) explainByResult.set(`${result.docid}\0${result.uri}`, detail);
407
+ }
408
+ }
409
+ const explain = input.explain
410
+ ? {
411
+ lines: responses.flatMap(
412
+ (response) => response.meta.explain?.lines ?? []
413
+ ),
414
+ results: results.flatMap((result) => {
415
+ const detail = explainByResult.get(`${result.docid}\0${result.uri}`);
416
+ const retrievalRank = plannerMeta(result)?.retrievalRank;
417
+ return detail
418
+ ? [
419
+ {
420
+ ...detail,
421
+ rank: retrievalRank ?? detail.rank,
422
+ score: result.score,
423
+ },
424
+ ]
425
+ : [];
426
+ }),
427
+ }
428
+ : undefined;
396
429
  const uriPrefix =
397
430
  input.uriPrefix === null || input.uriPrefix === undefined
398
431
  ? null
@@ -528,5 +561,9 @@ export const planContextEvidence = async <T, P>(
528
561
  projectCanonical: (state) =>
529
562
  deps.projectCanonical({ ...baseDraft, selection: state }),
530
563
  });
531
- return { ...baseDraft, ...selection };
564
+ return {
565
+ ...baseDraft,
566
+ ...selection,
567
+ ...(explain ? { explain } : {}),
568
+ };
532
569
  };
@@ -12,6 +12,7 @@ import type { RetrievalQrelsCase } from "./retrieval-qrels";
12
12
  import type { RetrievalReplayCandidate } from "./retrieval-replay-types";
13
13
 
14
14
  import { parseUri } from "../app/constants";
15
+ import { normalizeContentTypes } from "../config";
15
16
  import { searchHybrid } from "../pipeline/hybrid";
16
17
  import { searchBm25 } from "../pipeline/search";
17
18
  import { SEARCH_RESULTS_TRACE_METADATA } from "../pipeline/types";
@@ -145,7 +146,11 @@ const runCandidateOnce = async (
145
146
  options: HybridSearchOptions
146
147
  ): Promise<StoreResult<SearchResults>> => {
147
148
  if (candidate.type === "bm25") {
148
- return searchBm25(deps.store, source.query.text, options);
149
+ return searchBm25(deps.store, source.query.text, {
150
+ ...options,
151
+ contentTypeRules: normalizeContentTypes(deps.config.contentTypes ?? [])
152
+ .rules,
153
+ });
149
154
  }
150
155
  if (candidate.type === "vector") {
151
156
  if (!(deps.vectorIndex && deps.embedPort)) {
@@ -7,7 +7,10 @@
7
7
  import type { Config, NormalizedContentTypeRule } from "../config";
8
8
  import type { SyncOptions } from "./types";
9
9
 
10
- import { fingerprintContentTypeRules, normalizeContentTypes } from "../config";
10
+ import {
11
+ fingerprintContentTypeMetadataRules,
12
+ normalizeContentTypes,
13
+ } from "../config";
11
14
 
12
15
  export function resolveContentTypeRules(
13
16
  config?: Pick<Config, "contentTypes">
@@ -24,6 +27,7 @@ export function withContentTypeRules(
24
27
  ...options,
25
28
  contentTypeRules: rules,
26
29
  contentTypeRulesFingerprint:
27
- options.contentTypeRulesFingerprint ?? fingerprintContentTypeRules(rules),
30
+ options.contentTypeRulesFingerprint ??
31
+ fingerprintContentTypeMetadataRules(rules),
28
32
  };
29
33
  }
@@ -33,7 +33,10 @@ import type {
33
33
  WalkerPort,
34
34
  } from "./types";
35
35
 
36
- import { fingerprintContentTypeRules } from "../config";
36
+ import {
37
+ fingerprintContentTypeMetadataRules,
38
+ resolveContentTypeRule,
39
+ } from "../config";
37
40
  import { getDefaultMimeDetector, type MimeDetector } from "../converters/mime";
38
41
  import {
39
42
  type ConversionPipeline,
@@ -80,7 +83,8 @@ const MAX_CONCURRENCY = 16;
80
83
  * Documents with ingestVersion < INGEST_VERSION will be re-processed.
81
84
  */
82
85
  export const INGEST_VERSION = 6;
83
- const EMPTY_CONTENT_TYPE_RULES_FINGERPRINT = fingerprintContentTypeRules([]);
86
+ const EMPTY_CONTENT_TYPE_RULES_FINGERPRINT =
87
+ fingerprintContentTypeMetadataRules([]);
84
88
  const RELATION_EDGE_TYPE_PATTERN = /^[a-z][a-z0-9_]*$/;
85
89
  const PROJECTION_YIELD_INTERVAL = 25;
86
90
  const NON_RETRYABLE_CONVERSION_ERROR_CODES = new Set([
@@ -401,18 +405,6 @@ function parseCategories(input: unknown): string[] {
401
405
  return [];
402
406
  }
403
407
 
404
- function matchPrefixContentType(
405
- relPath: string,
406
- rules: NormalizedContentTypeRule[]
407
- ): string | undefined {
408
- for (const rule of rules) {
409
- if (rule.prefixes.some((prefix) => relPath.startsWith(prefix))) {
410
- return rule.id;
411
- }
412
- }
413
- return undefined;
414
- }
415
-
416
408
  export function extractDocumentMetadata(
417
409
  markdown: string,
418
410
  relPath: string,
@@ -421,23 +413,23 @@ export function extractDocumentMetadata(
421
413
  ): DocumentMetadata {
422
414
  const parsed = parseFrontmatter(markdown);
423
415
  const metadata = parsed.metadata;
424
- const typedRules = new Map(contentTypeRules.map((rule) => [rule.id, rule]));
425
416
  const rawFrontmatterType =
426
417
  typeof metadata.type === "string"
427
418
  ? normalizeFrontmatterScalar(metadata.type)
428
419
  : "";
429
- const frontmatterType = typedRules.get(rawFrontmatterType)?.id;
430
- const prefixType =
431
- frontmatterType === undefined
432
- ? matchPrefixContentType(relPath, contentTypeRules)
433
- : undefined;
420
+ const configuredRule = resolveContentTypeRule(
421
+ rawFrontmatterType,
422
+ relPath,
423
+ contentTypeRules
424
+ );
434
425
  const inferred = inferPathContentType(relPath, ext);
435
- const contentType = frontmatterType ?? prefixType ?? inferred.contentType;
436
- const contentTypeSource: ContentTypeSource = frontmatterType
437
- ? "frontmatter-type"
438
- : prefixType
439
- ? "prefix"
440
- : inferred.source;
426
+ const contentType = configuredRule?.rule.id ?? inferred.contentType;
427
+ const contentTypeSource: ContentTypeSource =
428
+ configuredRule?.source === "configured-id"
429
+ ? "frontmatter-type"
430
+ : configuredRule?.source === "prefix"
431
+ ? "prefix"
432
+ : inferred.source;
441
433
  const categories = new Set<string>([contentType]);
442
434
 
443
435
  const fmCategories = parseCategories(
@@ -679,7 +671,7 @@ export class SyncService {
679
671
  const contentTypeRules = options.contentTypeRules ?? [];
680
672
  const contentTypeRulesFingerprint =
681
673
  options.contentTypeRulesFingerprint ??
682
- fingerprintContentTypeRules(contentTypeRules);
674
+ fingerprintContentTypeMetadataRules(contentTypeRules);
683
675
 
684
676
  // 4. Check existing doc for skip/repair decision
685
677
  const existingResult = await store.getDocument(
@@ -1066,7 +1058,7 @@ export class SyncService {
1066
1058
  contentTypeRules: options.contentTypeRules ?? [],
1067
1059
  contentTypeRulesFingerprint:
1068
1060
  options.contentTypeRulesFingerprint ??
1069
- fingerprintContentTypeRules(options.contentTypeRules ?? []),
1061
+ fingerprintContentTypeMetadataRules(options.contentTypeRules ?? []),
1070
1062
  };
1071
1063
  const results: FileSyncResult[] = [];
1072
1064
  const projectionSourceIds = new Set<number>();
@@ -1411,7 +1403,7 @@ export class SyncService {
1411
1403
  contentTypeRules: options.contentTypeRules ?? [],
1412
1404
  contentTypeRulesFingerprint:
1413
1405
  options.contentTypeRulesFingerprint ??
1414
- fingerprintContentTypeRules(options.contentTypeRules ?? []),
1406
+ fingerprintContentTypeMetadataRules(options.contentTypeRules ?? []),
1415
1407
  };
1416
1408
  const errors: Array<{ relPath: string; code: string; message: string }> =
1417
1409
  [];
@@ -135,7 +135,7 @@ export interface SyncOptions {
135
135
  concurrency?: number;
136
136
  /** Normalized content type rules from config.contentTypes. */
137
137
  contentTypeRules?: NormalizedContentTypeRule[];
138
- /** Stable hash of the normalized content type rules, used for re-derivation. */
138
+ /** Stable hash of metadata-affecting rules, used for re-derivation. */
139
139
  contentTypeRulesFingerprint?: string;
140
140
  /** Internal orchestration flag: defer graph projection to an outer sync. */
141
141
  projectTypedEdges?: boolean;
@@ -50,6 +50,7 @@ export const askInputSchema = z
50
50
  graph: z.boolean().optional(),
51
51
  noGraph: z.boolean().optional(),
52
52
  noRerank: z.boolean().optional(),
53
+ explain: z.boolean().optional(),
53
54
  maxAnswerTokens: z.number().int().positive().optional(),
54
55
  contextBudgetTokens: z.number().int().positive().optional(),
55
56
  contextBudgetBytes: z.number().int().positive().optional(),
@@ -562,6 +562,10 @@ export const queryInputSchema = z.object({
562
562
  .boolean()
563
563
  .optional()
564
564
  .describe("Enable bounded one-hop graph neighbor expansion"),
565
+ explain: z
566
+ .boolean()
567
+ .optional()
568
+ .describe("Include deterministic stage and per-result scoring metadata"),
565
569
  tagsAll: z.array(z.string()).optional().describe("Require ALL of these tags"),
566
570
  tagsAny: z.array(z.string()).optional().describe("Require ANY of these tags"),
567
571
  });
@@ -22,7 +22,7 @@ import type { ToolContext } from "../server";
22
22
  import { decorateUriForIndex, parseUri } from "../../app/constants";
23
23
  import { createNonTtyProgressRenderer } from "../../cli/progress";
24
24
  import {
25
- fingerprintContentTypeRules,
25
+ fingerprintContentTypeMetadataRules,
26
26
  normalizeContentTypes,
27
27
  } from "../../config";
28
28
  import { resolveDepthPolicy } from "../../core/depth-policy";
@@ -71,6 +71,7 @@ interface QueryInput {
71
71
  graph?: boolean;
72
72
  tagsAll?: string[];
73
73
  tagsAny?: string[];
74
+ explain?: boolean;
74
75
  }
75
76
 
76
77
  interface QueryDiagnoseInput extends QueryInput {
@@ -260,6 +261,7 @@ export function handleQuery(
260
261
  tagsAll: normalizeTagFilters(args.tagsAll),
261
262
  tagsAny: normalizeTagFilters(args.tagsAny),
262
263
  projectAffinity,
264
+ explain: args.explain,
263
265
  };
264
266
 
265
267
  try {
@@ -538,7 +540,7 @@ export function handleQueryDiagnose(
538
540
  projectAffinity,
539
541
  contentTypeRules,
540
542
  contentTypeRulesFingerprint:
541
- fingerprintContentTypeRules(contentTypeRules),
543
+ fingerprintContentTypeMetadataRules(contentTypeRules),
542
544
  });
543
545
 
544
546
  if (!result.ok) {
@@ -11,6 +11,7 @@ import type { SearchResult, SearchResults } from "../../pipeline/types";
11
11
  import type { ToolContext } from "../server";
12
12
 
13
13
  import { decorateUriForIndex, parseUri } from "../../app/constants";
14
+ import { normalizeContentTypes } from "../../config";
14
15
  import { resolveRemoteProjectAffinity } from "../../core/project-affinity-surface";
15
16
  import {
16
17
  finishRetrievalTraceAfterError,
@@ -133,6 +134,8 @@ export function handleSearch(
133
134
  tagsAll: normalizeTagFilters(args.tagsAll),
134
135
  tagsAny: normalizeTagFilters(args.tagsAny),
135
136
  projectAffinity,
137
+ contentTypeRules: normalizeContentTypes(ctx.config.contentTypes ?? [])
138
+ .rules,
136
139
  };
137
140
  let traceSession: RetrievalTraceSession | undefined;
138
141
  try {