@gmickel/gno 1.25.1 → 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 (94) hide show
  1. package/README.md +12 -5
  2. package/assets/skill/SKILL.md +37 -17
  3. package/browser-extension/artifacts/{gno-browser-clipper-v1.25.1.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 +161 -6
  8. package/spec/db/schema.sql +1 -1
  9. package/spec/evals-agentic.md +17 -0
  10. package/spec/mcp.md +25 -6
  11. package/spec/output-schemas/ask.schema.json +3 -0
  12. package/spec/output-schemas/project-profile-apply.schema.json +209 -0
  13. package/spec/output-schemas/project-profile-command.schema.json +160 -0
  14. package/spec/output-schemas/query-diagnose.schema.json +68 -5
  15. package/spec/output-schemas/search-results.schema.json +87 -1
  16. package/spec/output-schemas/setup-profile-result.schema.json +87 -0
  17. package/spec/output-schemas/status.schema.json +24 -0
  18. package/spec/project-profile.schema.json +303 -0
  19. package/src/app/context-runtime-contract.ts +4 -1
  20. package/src/app/context-runtime-types.ts +2 -0
  21. package/src/app/context-runtime.ts +26 -0
  22. package/src/app/verified-ask.ts +6 -1
  23. package/src/cli/commands/ask.ts +8 -1
  24. package/src/cli/commands/collection/add.ts +39 -45
  25. package/src/cli/commands/collection/remove.ts +28 -28
  26. package/src/cli/commands/collection/rename.ts +55 -73
  27. package/src/cli/commands/context/add.ts +37 -26
  28. package/src/cli/commands/context/rm.ts +47 -20
  29. package/src/cli/commands/init.ts +55 -125
  30. package/src/cli/commands/models/use.ts +43 -38
  31. package/src/cli/commands/profile-apply.ts +334 -0
  32. package/src/cli/commands/profile.ts +409 -0
  33. package/src/cli/commands/query.ts +6 -3
  34. package/src/cli/commands/search.ts +6 -1
  35. package/src/cli/commands/setup-activation.ts +205 -54
  36. package/src/cli/commands/setup-profile.ts +223 -0
  37. package/src/cli/commands/setup.ts +3 -0
  38. package/src/cli/commands/status.ts +43 -7
  39. package/src/cli/program.ts +112 -9
  40. package/src/config/content-types.ts +82 -0
  41. package/src/config/index.ts +11 -0
  42. package/src/config/project-profile.ts +374 -0
  43. package/src/config/saver.ts +16 -7
  44. package/src/config/types.ts +53 -2
  45. package/src/core/config-mutation.ts +138 -76
  46. package/src/core/config-write-lock.ts +89 -0
  47. package/src/core/context-compiler.ts +38 -1
  48. package/src/core/context-identity.ts +16 -0
  49. package/src/core/context-resolver.ts +2 -12
  50. package/src/core/folder-setup-planning.ts +6 -21
  51. package/src/core/folder-setup.ts +30 -2
  52. package/src/core/path-rules.ts +53 -0
  53. package/src/core/project-affinity-surface.ts +102 -7
  54. package/src/core/project-profile-apply-state.ts +268 -0
  55. package/src/core/project-profile-apply-validation.ts +95 -0
  56. package/src/core/project-profile-apply.ts +408 -0
  57. package/src/core/project-profile-canonical.ts +71 -0
  58. package/src/core/project-profile-diff.ts +302 -0
  59. package/src/core/project-profile-discovery.ts +519 -0
  60. package/src/core/project-profile-file.ts +37 -0
  61. package/src/core/project-profile-parser.ts +98 -0
  62. package/src/core/project-profile.ts +490 -0
  63. package/src/core/retrieval-replay-candidate.ts +6 -1
  64. package/src/ingestion/sync-options.ts +6 -2
  65. package/src/ingestion/sync.ts +21 -29
  66. package/src/ingestion/types.ts +1 -1
  67. package/src/ingestion/walker.ts +85 -44
  68. package/src/llm/cache.ts +21 -0
  69. package/src/mcp/tools/ask.ts +1 -0
  70. package/src/mcp/tools/index.ts +4 -0
  71. package/src/mcp/tools/query.ts +4 -2
  72. package/src/mcp/tools/search.ts +3 -0
  73. package/src/mcp/tools/status.ts +4 -0
  74. package/src/pipeline/content-type-boost.ts +264 -0
  75. package/src/pipeline/diagnose.ts +46 -19
  76. package/src/pipeline/explain.ts +15 -2
  77. package/src/pipeline/hybrid.ts +170 -74
  78. package/src/pipeline/rerank.ts +45 -15
  79. package/src/pipeline/search.ts +29 -11
  80. package/src/pipeline/types.ts +13 -4
  81. package/src/pipeline/vsearch.ts +30 -10
  82. package/src/sdk/client.ts +19 -3
  83. package/src/sdk/index.ts +1 -0
  84. package/src/sdk/types.ts +21 -5
  85. package/src/serve/config-sync.ts +2 -2
  86. package/src/serve/resident-runtime.ts +1 -0
  87. package/src/serve/routes/api.ts +18 -3
  88. package/src/serve/status-model.ts +2 -0
  89. package/src/serve/status.ts +4 -0
  90. package/src/store/migrations/021-multi-context-identity.ts +37 -0
  91. package/src/store/migrations/index.ts +2 -0
  92. package/src/store/sqlite/adapter.ts +86 -0
  93. package/src/store/types.ts +3 -2
  94. package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip.sha256 +0 -1
@@ -20,6 +20,7 @@ import {
20
20
  import type { SkippedEntry, WalkConfig, WalkEntry, WalkerPort } from "./types";
21
21
 
22
22
  import { SUPPORTED_EXTENSIONS } from "../converters/mime";
23
+ import { matchesCollectionExclusion } from "../core/path-rules";
23
24
 
24
25
  /**
25
26
  * Regex to detect dangerous patterns with parent directory traversal.
@@ -51,6 +52,70 @@ function validatePattern(pattern: string): string | null {
51
52
  return null;
52
53
  }
53
54
 
55
+ /**
56
+ * Split GNO's canonical whole-pattern union into independently scannable Bun
57
+ * globs. Bun.Glob.match() accepts a leading `{a,b}` union, but scan() does not.
58
+ * Nested braces remain part of each child glob; escaped outer commas become
59
+ * literal commas again before scanning.
60
+ */
61
+ function scanPatterns(pattern: string): string[] {
62
+ if (!(pattern.startsWith("{") && pattern.endsWith("}"))) return [pattern];
63
+
64
+ const patterns: string[] = [];
65
+ let branch = "";
66
+ let depth = 0;
67
+ let bracketDepth = 0;
68
+ for (let index = 0; index < pattern.length; index += 1) {
69
+ const character = pattern[index];
70
+ if (
71
+ character === "\\" &&
72
+ pattern[index + 1] === "," &&
73
+ depth === 1 &&
74
+ bracketDepth === 0
75
+ ) {
76
+ branch += ",";
77
+ index += 1;
78
+ continue;
79
+ }
80
+ if (character === "[" && bracketDepth === 0) {
81
+ bracketDepth = 1;
82
+ branch += character;
83
+ continue;
84
+ }
85
+ if (character === "]" && bracketDepth > 0) {
86
+ bracketDepth = 0;
87
+ branch += character;
88
+ continue;
89
+ }
90
+ if (bracketDepth > 0) {
91
+ branch += character;
92
+ continue;
93
+ }
94
+ if (character === "{") {
95
+ depth += 1;
96
+ if (depth > 1) branch += character;
97
+ continue;
98
+ }
99
+ if (character === "}") {
100
+ depth -= 1;
101
+ if (depth < 0 || (depth === 0 && index !== pattern.length - 1)) {
102
+ return [pattern];
103
+ }
104
+ if (depth > 0) branch += character;
105
+ continue;
106
+ }
107
+ if (character === "," && depth === 1) {
108
+ patterns.push(branch);
109
+ branch = "";
110
+ continue;
111
+ }
112
+ branch += character;
113
+ }
114
+ if (depth !== 0 || bracketDepth !== 0) return [pattern];
115
+ patterns.push(branch);
116
+ return patterns;
117
+ }
118
+
54
119
  /**
55
120
  * Compute safe relative path from root to file.
56
121
  * Returns null if file is outside root (security check).
@@ -77,36 +142,6 @@ async function safeRelPath(
77
142
  }
78
143
  }
79
144
 
80
- /**
81
- * Check if a path matches any exclude pattern.
82
- *
83
- * Exclude semantics (component-based matching):
84
- * - Patterns match against path components (directory/file names)
85
- * - "node_modules" matches any path containing "node_modules" as a component
86
- * - ".git" matches ".git" directory at any level
87
- * - Patterns are NOT globs - they match exact component names
88
- *
89
- * Examples:
90
- * - exclude: [".git"] matches "foo/.git/bar" but not "foo/.github/..."
91
- * - exclude: ["dist"] matches "dist/bundle.js" and "src/dist/output.js"
92
- */
93
- function matchesExclude(relPath: string, excludes: string[]): boolean {
94
- const parts = relPath.split("/");
95
-
96
- for (const pattern of excludes) {
97
- // Check if any path component matches exactly
98
- if (parts.includes(pattern)) {
99
- return true;
100
- }
101
- // Check if path starts with pattern
102
- if (relPath.startsWith(`${pattern}/`)) {
103
- return true;
104
- }
105
- }
106
-
107
- return false;
108
- }
109
-
110
145
  /**
111
146
  * Check if a file extension matches the include list.
112
147
  * Include list contains extensions like ".md" or "md" (normalized).
@@ -145,10 +180,12 @@ export class FileWalker implements WalkerPort {
145
180
  const entries: WalkEntry[] = [];
146
181
  const skipped: SkippedEntry[] = [];
147
182
 
148
- // Validate pattern for security
149
- const patternError = validatePattern(config.pattern);
150
- if (patternError) {
151
- throw new Error(`Invalid glob pattern: ${patternError}`);
183
+ const patterns = scanPatterns(config.pattern);
184
+ for (const pattern of patterns) {
185
+ const patternError = validatePattern(pattern);
186
+ if (patternError) {
187
+ throw new Error(`Invalid glob pattern: ${patternError}`);
188
+ }
152
189
  }
153
190
 
154
191
  // Resolve root to real path for consistent comparison
@@ -161,16 +198,20 @@ export class FileWalker implements WalkerPort {
161
198
  return { entries: [], skipped: [] };
162
199
  }
163
200
 
164
- const glob = new Bun.Glob(config.pattern);
165
-
166
- for await (const match of glob.scan({
167
- cwd: rootReal,
168
- absolute: true,
169
- onlyFiles: true,
170
- followSymlinks: false,
171
- })) {
172
- const absPath = normalizePath(match);
201
+ const matches = new Set<string>();
202
+ for (const pattern of patterns) {
203
+ const glob = new Bun.Glob(pattern);
204
+ for await (const match of glob.scan({
205
+ cwd: rootReal,
206
+ absolute: true,
207
+ onlyFiles: true,
208
+ followSymlinks: false,
209
+ })) {
210
+ matches.add(normalizePath(match));
211
+ }
212
+ }
173
213
 
214
+ for (const absPath of [...matches].sort()) {
174
215
  // Security: Compute safe relative path (validates file is within root)
175
216
  const relPath = await safeRelPath(rootReal, absPath);
176
217
  if (relPath === null) {
@@ -179,7 +220,7 @@ export class FileWalker implements WalkerPort {
179
220
  }
180
221
 
181
222
  // Check exclude patterns
182
- if (matchesExclude(relPath, config.exclude)) {
223
+ if (matchesCollectionExclusion(relPath, config.exclude)) {
183
224
  skipped.push({
184
225
  absPath,
185
226
  relPath,
package/src/llm/cache.ts CHANGED
@@ -527,6 +527,27 @@ export class ModelCache {
527
527
  return cached !== null;
528
528
  }
529
529
 
530
+ /**
531
+ * Read-only availability probe for inspection commands.
532
+ *
533
+ * Unlike isCached(), this never repairs manifest entries or removes invalid
534
+ * files. It is safe for commands whose contract forbids local mutation.
535
+ */
536
+ async isCachedReadOnly(uri: string): Promise<boolean> {
537
+ const parsed = parseModelUri(uri);
538
+ if (!parsed.ok) return false;
539
+ if (parsed.value.scheme === "file") {
540
+ const validation = await validateGgufFile(parsed.value.file, uri, "user");
541
+ return validation.ok;
542
+ }
543
+
544
+ const manifest = await this.readManifestFromDisk();
545
+ const entry = manifest.models.find((model) => model.uri === uri);
546
+ if (!entry || !(await this.fileExists(entry.path))) return false;
547
+ const validation = await validateGgufFile(entry.path, uri, "cache");
548
+ return validation.ok;
549
+ }
550
+
530
551
  /**
531
552
  * Get cached/available path for a URI.
532
553
  * For file: URIs, returns path if file exists.
@@ -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 {
@@ -7,6 +7,7 @@
7
7
  import type { IndexStatus } from "../../store/types";
8
8
  import type { ToolContext } from "../server";
9
9
 
10
+ import { buildContentTypeBoostStatus } from "../../config/content-types";
10
11
  import { resolveModelUri } from "../../llm/registry";
11
12
  import { createStandaloneResidentStatus } from "../../serve/resident-status";
12
13
  import { runTool, type ToolResult } from "./index";
@@ -87,6 +88,9 @@ export function handleStatus(
87
88
  return {
88
89
  ...result.value,
89
90
  configPath: ctx.actualConfigPath,
91
+ contentTypeBoost: buildContentTypeBoostStatus(
92
+ ctx.config.contentTypes ?? []
93
+ ),
90
94
  resident:
91
95
  ctx.getResidentStatus?.() ?? createStandaloneResidentStatus("stdio"),
92
96
  };
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Bounded content-type scoring composed with trusted project affinity.
3
+ *
4
+ * @module src/pipeline/content-type-boost
5
+ */
6
+
7
+ import type { NormalizedContentTypeRule } from "../config/content-types";
8
+ import type { ProjectAffinityScoringInput } from "./project-affinity";
9
+ import type { SearchResult } from "./types";
10
+
11
+ import {
12
+ fingerprintContentTypeRules,
13
+ resolveContentTypeRule,
14
+ } from "../config/content-types";
15
+ import {
16
+ CONTENT_TYPE_SEARCH_BOOST_MAX,
17
+ CONTENT_TYPE_SEARCH_BOOST_MIN,
18
+ CONTENT_TYPE_SEARCH_BOOST_NEUTRAL,
19
+ } from "../config/types";
20
+ import {
21
+ applyAuxiliaryScore,
22
+ hasProjectAffinity,
23
+ SEARCH_RESULT_AFFINITY_METADATA,
24
+ scoreProjectAffinity,
25
+ type ProjectAffinityScoreMetadata,
26
+ } from "./project-affinity";
27
+
28
+ export const CONTENT_TYPE_MAX_CONTRIBUTION = 0.05;
29
+
30
+ export interface ContentTypeBoostScoreMetadata {
31
+ baseScore: number;
32
+ cappedContribution: number;
33
+ combinedAuxiliaryApplied: number;
34
+ combinedAuxiliaryCap: number;
35
+ combinedAuxiliaryRequested: number;
36
+ configuredFactor: number;
37
+ contentType: string;
38
+ finalScore: number;
39
+ rawContribution: number;
40
+ rawScore: number;
41
+ rawScoreKind: ProjectAffinityScoreMetadata["rawScoreKind"];
42
+ ruleSource: "configured-id" | "prefix";
43
+ rulesFingerprint: string;
44
+ }
45
+
46
+ export interface AuxiliaryScoreResult {
47
+ contentTypeBoost?: ContentTypeBoostScoreMetadata;
48
+ projectAffinity: ProjectAffinityScoreMetadata;
49
+ }
50
+
51
+ export const SEARCH_RESULT_CONTENT_TYPE_BOOST_METADATA = Symbol(
52
+ "gno.searchResultContentTypeBoostMetadata"
53
+ );
54
+
55
+ const clamp = (value: number, min: number, max: number): number =>
56
+ Math.min(max, Math.max(min, value));
57
+
58
+ const rankingFingerprints = new WeakMap<
59
+ readonly NormalizedContentTypeRule[],
60
+ string
61
+ >();
62
+
63
+ const rankingFingerprint = (
64
+ rules: readonly NormalizedContentTypeRule[] | undefined
65
+ ): string => {
66
+ if (!rules) return fingerprintContentTypeRules([]);
67
+ const cached = rankingFingerprints.get(rules);
68
+ if (cached) return cached;
69
+ const fingerprint = fingerprintContentTypeRules([...rules]);
70
+ rankingFingerprints.set(rules, fingerprint);
71
+ return fingerprint;
72
+ };
73
+
74
+ /** Map the supported factor range continuously onto the contribution range. */
75
+ export function contentTypeBoostContribution(factor: number): {
76
+ raw: number;
77
+ capped: number;
78
+ } {
79
+ const raw =
80
+ factor >= CONTENT_TYPE_SEARCH_BOOST_NEUTRAL
81
+ ? ((factor - CONTENT_TYPE_SEARCH_BOOST_NEUTRAL) /
82
+ (CONTENT_TYPE_SEARCH_BOOST_MAX - CONTENT_TYPE_SEARCH_BOOST_NEUTRAL)) *
83
+ CONTENT_TYPE_MAX_CONTRIBUTION
84
+ : ((factor - CONTENT_TYPE_SEARCH_BOOST_NEUTRAL) /
85
+ (CONTENT_TYPE_SEARCH_BOOST_NEUTRAL - CONTENT_TYPE_SEARCH_BOOST_MIN)) *
86
+ CONTENT_TYPE_MAX_CONTRIBUTION;
87
+ return {
88
+ raw,
89
+ capped: clamp(
90
+ raw,
91
+ -CONTENT_TYPE_MAX_CONTRIBUTION,
92
+ CONTENT_TYPE_MAX_CONTRIBUTION
93
+ ),
94
+ };
95
+ }
96
+
97
+ export function hasContentTypeBoost(
98
+ rules: readonly NormalizedContentTypeRule[] | undefined
99
+ ): boolean {
100
+ return Boolean(
101
+ rules?.some(
102
+ (rule) => rule.searchBoost !== CONTENT_TYPE_SEARCH_BOOST_NEUTRAL
103
+ )
104
+ );
105
+ }
106
+
107
+ export function hasAuxiliaryRanking(
108
+ projectAffinity: ProjectAffinityScoringInput | undefined,
109
+ rules: readonly NormalizedContentTypeRule[] | undefined
110
+ ): boolean {
111
+ return hasProjectAffinity(projectAffinity) || hasContentTypeBoost(rules);
112
+ }
113
+
114
+ export function scoreContentTypeBoost(
115
+ baseScore: number,
116
+ contentType: string | undefined,
117
+ contentTypeSource: string | null | undefined,
118
+ relativePath: string,
119
+ collection: string,
120
+ rules: readonly NormalizedContentTypeRule[] | undefined,
121
+ projectAffinity: ProjectAffinityScoringInput | undefined,
122
+ raw: {
123
+ kind: ProjectAffinityScoreMetadata["rawScoreKind"];
124
+ score: number;
125
+ } = { kind: "normalized", score: baseScore }
126
+ ): AuxiliaryScoreResult {
127
+ const configuredId =
128
+ contentTypeSource === "frontmatter-type" ||
129
+ contentTypeSource === "frontmatter"
130
+ ? contentType
131
+ : undefined;
132
+ const resolution = resolveContentTypeRule(
133
+ configuredId,
134
+ relativePath,
135
+ rules ? [...rules] : []
136
+ );
137
+ const factor =
138
+ resolution?.rule.searchBoost ?? CONTENT_TYPE_SEARCH_BOOST_NEUTRAL;
139
+ const contribution = contentTypeBoostContribution(factor);
140
+ const projectScore = scoreProjectAffinity(
141
+ baseScore,
142
+ collection,
143
+ projectAffinity,
144
+ raw
145
+ );
146
+ const combined = applyAuxiliaryScore(baseScore, [
147
+ projectScore.affinityRequested,
148
+ contribution.capped,
149
+ ]);
150
+ const compositeProjectScore: ProjectAffinityScoreMetadata = {
151
+ ...projectScore,
152
+ combinedAuxiliaryApplied: combined.applied,
153
+ combinedAuxiliaryRequested: combined.requested,
154
+ finalBlendedScore: combined.finalScore,
155
+ finalScore: combined.finalScore,
156
+ };
157
+
158
+ if (!resolution || factor === CONTENT_TYPE_SEARCH_BOOST_NEUTRAL) {
159
+ return { projectAffinity: compositeProjectScore };
160
+ }
161
+
162
+ return {
163
+ projectAffinity: compositeProjectScore,
164
+ contentTypeBoost: {
165
+ baseScore,
166
+ cappedContribution: contribution.capped,
167
+ combinedAuxiliaryApplied: compositeProjectScore.combinedAuxiliaryApplied,
168
+ combinedAuxiliaryCap: compositeProjectScore.combinedAuxiliaryCap,
169
+ combinedAuxiliaryRequested:
170
+ compositeProjectScore.combinedAuxiliaryRequested,
171
+ configuredFactor: factor,
172
+ contentType: resolution.rule.id,
173
+ finalScore: compositeProjectScore.finalScore,
174
+ rawContribution: contribution.raw,
175
+ rawScore: raw.score,
176
+ rawScoreKind: raw.kind,
177
+ ruleSource: resolution.source,
178
+ rulesFingerprint: rankingFingerprint(rules),
179
+ },
180
+ };
181
+ }
182
+
183
+ export function attachAuxiliaryScoreMetadata(
184
+ result: SearchResult,
185
+ scored: AuxiliaryScoreResult,
186
+ finalScore: number,
187
+ includeProjectAffinity = false
188
+ ): SearchResult {
189
+ const projectAffinity = {
190
+ ...scored.projectAffinity,
191
+ finalBlendedScore: finalScore,
192
+ finalScore,
193
+ };
194
+ result.score = finalScore;
195
+ if (includeProjectAffinity) {
196
+ Object.defineProperty(result, SEARCH_RESULT_AFFINITY_METADATA, {
197
+ configurable: true,
198
+ enumerable: false,
199
+ value: projectAffinity,
200
+ writable: true,
201
+ });
202
+ }
203
+ if (scored.contentTypeBoost) {
204
+ Object.defineProperty(result, SEARCH_RESULT_CONTENT_TYPE_BOOST_METADATA, {
205
+ configurable: true,
206
+ enumerable: false,
207
+ value: { ...scored.contentTypeBoost, finalScore },
208
+ writable: true,
209
+ });
210
+ }
211
+ return result;
212
+ }
213
+
214
+ export function applyContentTypeBoost(
215
+ result: SearchResult,
216
+ collection: string,
217
+ rules: readonly NormalizedContentTypeRule[] | undefined,
218
+ projectAffinity: ProjectAffinityScoringInput | undefined,
219
+ contentTypeSource?: string | null,
220
+ raw?: {
221
+ kind: ProjectAffinityScoreMetadata["rawScoreKind"];
222
+ score: number;
223
+ }
224
+ ): SearchResult {
225
+ const scored = scoreContentTypeBoost(
226
+ result.score,
227
+ result.contentType,
228
+ contentTypeSource,
229
+ result.source.relPath,
230
+ collection,
231
+ rules,
232
+ projectAffinity,
233
+ raw
234
+ );
235
+ const affinityActive = hasProjectAffinity(projectAffinity);
236
+ if (!(scored.contentTypeBoost || affinityActive)) return result;
237
+ return attachAuxiliaryScoreMetadata(
238
+ result,
239
+ scored,
240
+ scored.projectAffinity.finalScore,
241
+ affinityActive
242
+ );
243
+ }
244
+
245
+ export function getContentTypeBoostMetadata(
246
+ result: SearchResult
247
+ ): ContentTypeBoostScoreMetadata | undefined {
248
+ return (
249
+ result as SearchResult & {
250
+ [SEARCH_RESULT_CONTENT_TYPE_BOOST_METADATA]?: ContentTypeBoostScoreMetadata;
251
+ }
252
+ )[SEARCH_RESULT_CONTENT_TYPE_BOOST_METADATA];
253
+ }
254
+
255
+ export function sortByFinalScoreStable(results: SearchResult[]): void {
256
+ const originalRank = new Map(
257
+ results.map((result, index) => [result, index] as const)
258
+ );
259
+ results.sort(
260
+ (left, right) =>
261
+ right.score - left.score ||
262
+ (originalRank.get(left) ?? 0) - (originalRank.get(right) ?? 0)
263
+ );
264
+ }
@@ -14,16 +14,23 @@ import type {
14
14
  QueryDiagnoseTraceCandidate,
15
15
  } from "./types";
16
16
 
17
- import { fingerprintContentTypeRules } from "../config";
17
+ import {
18
+ fingerprintContentTypeMetadataRules,
19
+ normalizeContentTypes,
20
+ } from "../config";
18
21
  import { resolveDocRef } from "../core/ref-parser";
19
22
  import { err, ok } from "../store/types";
23
+ import {
24
+ getContentTypeBoostMetadata,
25
+ scoreContentTypeBoost,
26
+ type ContentTypeBoostScoreMetadata,
27
+ } from "./content-type-boost";
20
28
  import { evaluateQueryTargetFilters } from "./filters";
21
29
  import { searchHybrid } from "./hybrid";
22
30
  import {
23
31
  getProjectAffinityMetadata,
24
32
  type ProjectAffinityScoringInput,
25
33
  type ProjectAffinityScoreMetadata,
26
- scoreProjectAffinity,
27
34
  } from "./project-affinity";
28
35
 
29
36
  export type QueryDiagnoseTargetStatus =
@@ -52,7 +59,7 @@ export interface QueryDiagnoseStage {
52
59
  }
53
60
 
54
61
  export interface QueryDiagnoseResult {
55
- schemaVersion: "1.0" | "1.1";
62
+ schemaVersion: "1.0" | "1.1" | "1.2";
56
63
  query: string;
57
64
  target: {
58
65
  ref: string;
@@ -72,6 +79,7 @@ export interface QueryDiagnoseResult {
72
79
  };
73
80
  stages: QueryDiagnoseStage[];
74
81
  affinity?: ProjectAffinityScoreMetadata;
82
+ contentTypeBoost?: ContentTypeBoostScoreMetadata;
75
83
  chunk: {
76
84
  seq: number | null;
77
85
  startLine: number | null;
@@ -182,9 +190,12 @@ export async function diagnoseQueryTarget(
182
190
  }
183
191
 
184
192
  const doc = resolved.doc;
185
- const rules = options.contentTypeRules ?? [];
193
+ const rules =
194
+ options.contentTypeRules ??
195
+ normalizeContentTypes(deps.config.contentTypes ?? []).rules;
186
196
  const expectedFingerprint =
187
- options.contentTypeRulesFingerprint ?? fingerprintContentTypeRules(rules);
197
+ options.contentTypeRulesFingerprint ??
198
+ fingerprintContentTypeMetadataRules(rules);
188
199
  const fingerprintMatches = doc.contentTypeRulesFingerprint
189
200
  ? doc.contentTypeRulesFingerprint === expectedFingerprint
190
201
  : null;
@@ -302,16 +313,24 @@ export async function diagnoseQueryTarget(
302
313
  (candidate) =>
303
314
  candidate.mirrorHash === doc.mirrorHash && targetSeqs.has(candidate.seq)
304
315
  );
316
+ const fallbackScore = lastMatched
317
+ ? scoreContentTypeBoost(
318
+ lastMatched.score,
319
+ doc.contentType ?? undefined,
320
+ doc.contentTypeSource,
321
+ doc.relPath,
322
+ doc.collection,
323
+ rules,
324
+ options.projectAffinity,
325
+ { kind: "hybrid_blended", score: lastMatched.score }
326
+ )
327
+ : undefined;
305
328
  const affinity =
306
329
  (targetResult ? getProjectAffinityMetadata(targetResult) : undefined) ??
307
- (lastMatched && options.projectAffinity
308
- ? scoreProjectAffinity(
309
- lastMatched.score,
310
- doc.collection,
311
- options.projectAffinity,
312
- { kind: "hybrid_blended", score: lastMatched.score }
313
- )
314
- : null);
330
+ (options.projectAffinity ? fallbackScore?.projectAffinity : undefined);
331
+ const contentTypeBoost =
332
+ (targetResult ? getContentTypeBoostMetadata(targetResult) : undefined) ??
333
+ fallbackScore?.contentTypeBoost;
315
334
 
316
335
  const baseResult: QueryDiagnoseResult = {
317
336
  ...buildBaseResult(query, options.target, "diagnosed", doc, {
@@ -335,13 +354,21 @@ export async function diagnoseQueryTarget(
335
354
  queryModes: searchResult.value.meta.queryModes,
336
355
  },
337
356
  };
338
- return ok(
357
+ const trustedAffinity =
339
358
  affinity && hasTrustedProjectAffinityInput(options.projectAffinity)
340
- ? {
341
- ...baseResult,
342
- schemaVersion: "1.1",
343
- affinity,
344
- }
359
+ ? affinity
360
+ : undefined;
361
+ if (contentTypeBoost) {
362
+ return ok({
363
+ ...baseResult,
364
+ schemaVersion: "1.2",
365
+ ...(trustedAffinity ? { affinity: trustedAffinity } : {}),
366
+ contentTypeBoost,
367
+ });
368
+ }
369
+ return ok(
370
+ trustedAffinity
371
+ ? { ...baseResult, schemaVersion: "1.1", affinity: trustedAffinity }
345
372
  : baseResult
346
373
  );
347
374
  }