@gmickel/gno 1.21.0 → 1.23.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.
- package/README.md +26 -2
- package/assets/skill/SKILL.md +15 -0
- package/package.json +2 -1
- package/spec/cli.md +80 -20
- package/spec/evals-agentic.md +83 -0
- package/spec/evals.md +6 -0
- package/spec/mcp.md +18 -0
- package/spec/output-schemas/publish-artifact.schema.json +284 -0
- package/spec/output-schemas/query-diagnose-v1.schema.json +123 -0
- package/spec/output-schemas/query-diagnose.schema.json +89 -2
- package/src/app/context-runtime-types.ts +3 -0
- package/src/app/context-runtime.ts +1 -0
- package/src/app/context-surface.ts +4 -2
- package/src/cli/commands/ask.ts +31 -20
- package/src/cli/commands/context-build.ts +17 -7
- package/src/cli/commands/query.ts +58 -37
- package/src/cli/commands/search.ts +29 -19
- package/src/cli/commands/vsearch.ts +31 -22
- package/src/cli/options.ts +39 -0
- package/src/cli/program.ts +48 -0
- package/src/config/defaults.ts +10 -1
- package/src/config/types.ts +71 -0
- package/src/core/project-affinity-surface.ts +114 -0
- package/src/core/project-affinity.ts +330 -0
- package/src/core/validation.ts +20 -1
- package/src/mcp/tools/ask.ts +10 -1
- package/src/mcp/tools/context.ts +18 -0
- package/src/mcp/tools/index.ts +13 -2
- package/src/mcp/tools/query.ts +12 -0
- package/src/mcp/tools/search.ts +7 -0
- package/src/mcp/tools/vsearch.ts +7 -0
- package/src/pipeline/diagnose.ts +48 -3
- package/src/pipeline/explain.ts +54 -13
- package/src/pipeline/hybrid.ts +100 -59
- package/src/pipeline/project-affinity.ts +162 -0
- package/src/pipeline/search.ts +76 -10
- package/src/pipeline/types.ts +9 -0
- package/src/pipeline/vsearch.ts +117 -91
- package/src/publish/artifact-validation.ts +259 -0
- package/src/publish/artifact.ts +234 -118
- package/src/publish/export-service.ts +5 -9
- package/src/publish/metadata.ts +195 -0
- package/src/sdk/client.ts +80 -20
- package/src/sdk/index.ts +2 -0
- package/src/sdk/types.ts +20 -7
- package/src/serve/context-capsule.ts +18 -1
- package/src/serve/routes/api.ts +69 -0
package/src/config/types.ts
CHANGED
|
@@ -115,6 +115,74 @@ export const CollectionSchema = z.object({
|
|
|
115
115
|
export type Collection = z.infer<typeof CollectionSchema>;
|
|
116
116
|
export type CollectionModelOverrides = NonNullable<Collection["models"]>;
|
|
117
117
|
|
|
118
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
119
|
+
// Project Affinity Input
|
|
120
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export const TrustedProjectRootSourceSchema = z.enum([
|
|
123
|
+
"cli_cwd",
|
|
124
|
+
"cli_explicit",
|
|
125
|
+
"cli_worktree",
|
|
126
|
+
]);
|
|
127
|
+
export type TrustedProjectRootSource = z.infer<
|
|
128
|
+
typeof TrustedProjectRootSourceSchema
|
|
129
|
+
>;
|
|
130
|
+
|
|
131
|
+
export const LocalProjectAffinityRootSchema = z.object({
|
|
132
|
+
source: TrustedProjectRootSourceSchema,
|
|
133
|
+
path: z.string().min(1),
|
|
134
|
+
});
|
|
135
|
+
export type LocalProjectAffinityRoot = z.infer<
|
|
136
|
+
typeof LocalProjectAffinityRootSchema
|
|
137
|
+
>;
|
|
138
|
+
|
|
139
|
+
export const RemoteProjectAffinityRootSchema = z.object({
|
|
140
|
+
source: z.literal("remote_hint"),
|
|
141
|
+
hint: z.string().min(1),
|
|
142
|
+
});
|
|
143
|
+
export type RemoteProjectAffinityRoot = z.infer<
|
|
144
|
+
typeof RemoteProjectAffinityRootSchema
|
|
145
|
+
>;
|
|
146
|
+
|
|
147
|
+
export const ProjectAffinityRootSchema = z.discriminatedUnion("source", [
|
|
148
|
+
LocalProjectAffinityRootSchema,
|
|
149
|
+
RemoteProjectAffinityRootSchema,
|
|
150
|
+
]);
|
|
151
|
+
export type ProjectAffinityRoot = z.infer<typeof ProjectAffinityRootSchema>;
|
|
152
|
+
|
|
153
|
+
export const LocalProjectAffinityInputSchema = z.object({
|
|
154
|
+
roots: z.array(LocalProjectAffinityRootSchema).max(16).default([]),
|
|
155
|
+
});
|
|
156
|
+
export type LocalProjectAffinityInput = z.infer<
|
|
157
|
+
typeof LocalProjectAffinityInputSchema
|
|
158
|
+
>;
|
|
159
|
+
|
|
160
|
+
export const RemoteProjectAffinityInputSchema = z.object({
|
|
161
|
+
roots: z.array(RemoteProjectAffinityRootSchema).max(16).default([]),
|
|
162
|
+
});
|
|
163
|
+
export type RemoteProjectAffinityInput = z.infer<
|
|
164
|
+
typeof RemoteProjectAffinityInputSchema
|
|
165
|
+
>;
|
|
166
|
+
|
|
167
|
+
export const ProjectAffinityInputSchema = z.object({
|
|
168
|
+
roots: z.array(ProjectAffinityRootSchema).max(16).default([]),
|
|
169
|
+
});
|
|
170
|
+
export type ProjectAffinityInput = z.infer<typeof ProjectAffinityInputSchema>;
|
|
171
|
+
|
|
172
|
+
export const PROJECT_AFFINITY_MAX_CONTRIBUTION = 0.03;
|
|
173
|
+
export const AUXILIARY_RANKING_MAX_CONTRIBUTION = 0.08;
|
|
174
|
+
|
|
175
|
+
export const ProjectAffinityConfigSchema = z.object({
|
|
176
|
+
enabled: z.boolean().default(true),
|
|
177
|
+
contribution: z
|
|
178
|
+
.number()
|
|
179
|
+
.finite()
|
|
180
|
+
.min(0)
|
|
181
|
+
.max(PROJECT_AFFINITY_MAX_CONTRIBUTION)
|
|
182
|
+
.default(PROJECT_AFFINITY_MAX_CONTRIBUTION),
|
|
183
|
+
});
|
|
184
|
+
export type ProjectAffinityConfig = z.infer<typeof ProjectAffinityConfigSchema>;
|
|
185
|
+
|
|
118
186
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
119
187
|
// Context Schema
|
|
120
188
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -342,6 +410,9 @@ export const ConfigSchema = z.object({
|
|
|
342
410
|
|
|
343
411
|
/** Private local retrieval trace recording. Absent means recording off. */
|
|
344
412
|
retrievalTraces: RetrievalTraceConfigSchema.optional(),
|
|
413
|
+
|
|
414
|
+
/** Bounded project-aware retrieval affinity. */
|
|
415
|
+
projectAffinity: ProjectAffinityConfigSchema.optional(),
|
|
345
416
|
});
|
|
346
417
|
|
|
347
418
|
export type Config = Omit<z.infer<typeof ConfigSchema>, "contentTypes"> & {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared trust boundary for caller-supplied project affinity.
|
|
3
|
+
*
|
|
4
|
+
* Raw roots and hints stop here. Retrieval pipelines receive only the resolved,
|
|
5
|
+
* redaction-safe scoring input.
|
|
6
|
+
*
|
|
7
|
+
* @module src/core/project-affinity-surface
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Config, ProjectAffinityInput } from "../config/types";
|
|
11
|
+
import type { ProjectAffinityScoringInput } from "../pipeline/project-affinity";
|
|
12
|
+
|
|
13
|
+
import { resolveProjectAffinity } from "./project-affinity";
|
|
14
|
+
|
|
15
|
+
export const MAX_PROJECT_AFFINITY_INPUTS = 16;
|
|
16
|
+
|
|
17
|
+
export interface CliProjectAffinityRequest {
|
|
18
|
+
projectAffinityDisabled?: boolean;
|
|
19
|
+
projectRoots?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ProjectAffinityInputError extends Error {
|
|
23
|
+
constructor(message: string) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "ProjectAffinityInputError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const compareCodeUnits = (left: string, right: string): number =>
|
|
30
|
+
left < right ? -1 : left > right ? 1 : 0;
|
|
31
|
+
|
|
32
|
+
export const normalizeProjectAffinityValues = (
|
|
33
|
+
values: readonly string[] | undefined,
|
|
34
|
+
label: "project hints" | "project roots"
|
|
35
|
+
): string[] => {
|
|
36
|
+
if (values === undefined) return [];
|
|
37
|
+
if (
|
|
38
|
+
!Array.isArray(values) ||
|
|
39
|
+
values.length > MAX_PROJECT_AFFINITY_INPUTS ||
|
|
40
|
+
values.some((value) => typeof value !== "string")
|
|
41
|
+
) {
|
|
42
|
+
throw new ProjectAffinityInputError(
|
|
43
|
+
`${label} must contain at most ${MAX_PROJECT_AFFINITY_INPUTS} strings`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const normalized = [
|
|
47
|
+
...new Set(values.map((value) => value.normalize("NFC").trim())),
|
|
48
|
+
].sort(compareCodeUnits);
|
|
49
|
+
if (normalized.some((value) => value.length === 0)) {
|
|
50
|
+
throw new ProjectAffinityInputError(
|
|
51
|
+
`${label} must not contain empty values`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return normalized;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const scoringInput = async (
|
|
58
|
+
input: ProjectAffinityInput,
|
|
59
|
+
config: Config,
|
|
60
|
+
channel: "local" | "remote"
|
|
61
|
+
): Promise<ProjectAffinityScoringInput> => ({
|
|
62
|
+
enabled: config.projectAffinity?.enabled,
|
|
63
|
+
contribution: config.projectAffinity?.contribution,
|
|
64
|
+
resolution: await resolveProjectAffinity(input, config.collections, {
|
|
65
|
+
channel,
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const resolveCliProjectAffinity = async (
|
|
70
|
+
config: Config,
|
|
71
|
+
options: {
|
|
72
|
+
cwd: string;
|
|
73
|
+
disabled?: boolean;
|
|
74
|
+
projectRoots?: readonly string[];
|
|
75
|
+
}
|
|
76
|
+
): Promise<ProjectAffinityScoringInput | undefined> => {
|
|
77
|
+
const projectRoots = normalizeProjectAffinityValues(
|
|
78
|
+
options.projectRoots,
|
|
79
|
+
"project roots"
|
|
80
|
+
);
|
|
81
|
+
if (options.disabled && projectRoots.length > 0) {
|
|
82
|
+
throw new ProjectAffinityInputError(
|
|
83
|
+
"--no-project-affinity cannot be combined with --project-root"
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (options.disabled || config.projectAffinity?.enabled === false) return;
|
|
87
|
+
|
|
88
|
+
const roots =
|
|
89
|
+
projectRoots.length > 0
|
|
90
|
+
? projectRoots.map((path) => ({
|
|
91
|
+
path,
|
|
92
|
+
source: "cli_explicit" as const,
|
|
93
|
+
}))
|
|
94
|
+
: [{ path: options.cwd, source: "cli_cwd" as const }];
|
|
95
|
+
return scoringInput({ roots }, config, "local");
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const resolveRemoteProjectAffinity = async (
|
|
99
|
+
config: Config,
|
|
100
|
+
projectHints: readonly string[] | undefined
|
|
101
|
+
): Promise<ProjectAffinityScoringInput | undefined> => {
|
|
102
|
+
const hints = normalizeProjectAffinityValues(projectHints, "project hints");
|
|
103
|
+
if (hints.length === 0 || config.projectAffinity?.enabled === false) return;
|
|
104
|
+
return scoringInput(
|
|
105
|
+
{
|
|
106
|
+
roots: hints.map((hint) => ({
|
|
107
|
+
hint,
|
|
108
|
+
source: "remote_hint" as const,
|
|
109
|
+
})),
|
|
110
|
+
},
|
|
111
|
+
config,
|
|
112
|
+
"remote"
|
|
113
|
+
);
|
|
114
|
+
};
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trusted project-root resolution for project-aware retrieval affinity.
|
|
3
|
+
*
|
|
4
|
+
* This module resolves local roots only. Remote hints remain opaque and never
|
|
5
|
+
* trigger filesystem probes. Returned metadata is path-redacted; ranking is a
|
|
6
|
+
* separate concern.
|
|
7
|
+
*
|
|
8
|
+
* @module src/core/project-affinity
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// node:fs/promises for directory stat/realpath (no Bun directory equivalent)
|
|
12
|
+
import { realpath, stat } from "node:fs/promises";
|
|
13
|
+
// node:path for path utilities (no Bun path utils)
|
|
14
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
Collection,
|
|
18
|
+
ProjectAffinityInput,
|
|
19
|
+
ProjectAffinityRoot,
|
|
20
|
+
TrustedProjectRootSource,
|
|
21
|
+
} from "../config/types";
|
|
22
|
+
|
|
23
|
+
import { isCanonicalPathContained } from "./validation";
|
|
24
|
+
|
|
25
|
+
export type ProjectAffinityZeroReason =
|
|
26
|
+
| "no_collection_match"
|
|
27
|
+
| "root_unavailable"
|
|
28
|
+
| "untrusted_remote_hint";
|
|
29
|
+
|
|
30
|
+
export type ProjectAffinityRelation =
|
|
31
|
+
| "collection_contains_root"
|
|
32
|
+
| "exact"
|
|
33
|
+
| "root_contains_collection";
|
|
34
|
+
|
|
35
|
+
export interface ProjectAffinityMatch {
|
|
36
|
+
collection: string;
|
|
37
|
+
collectionAlias: string;
|
|
38
|
+
distance: number;
|
|
39
|
+
relation: ProjectAffinityRelation;
|
|
40
|
+
rootAlias: string;
|
|
41
|
+
source: TrustedProjectRootSource;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ProjectAffinityRootMetadata {
|
|
45
|
+
collectionAliases: string[];
|
|
46
|
+
reason: ProjectAffinityZeroReason | null;
|
|
47
|
+
repositoryRootDiscovered: boolean;
|
|
48
|
+
rootAlias: string;
|
|
49
|
+
source: ProjectAffinityRoot["source"];
|
|
50
|
+
status: "matched" | "zero";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ProjectAffinityResolution {
|
|
54
|
+
matches: ProjectAffinityMatch[];
|
|
55
|
+
roots: ProjectAffinityRootMetadata[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ProjectAffinityResolverDependencies {
|
|
59
|
+
canonicalizePath: (path: string) => Promise<string>;
|
|
60
|
+
discoverRepositoryRoot: (path: string) => Promise<string | null>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Trust is supplied by the invoking surface, never inferred from payload data.
|
|
65
|
+
*/
|
|
66
|
+
export interface ProjectAffinityResolverContext {
|
|
67
|
+
channel: "local" | "remote";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface CanonicalCollection {
|
|
71
|
+
alias: string;
|
|
72
|
+
name: string;
|
|
73
|
+
path: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface CanonicalTrustedRoot {
|
|
77
|
+
path: string;
|
|
78
|
+
repositoryRootDiscovered: boolean;
|
|
79
|
+
rootAlias: string;
|
|
80
|
+
source: TrustedProjectRootSource;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const alias = (namespace: "collection" | "root", value: string): string => {
|
|
84
|
+
const hash = new Bun.CryptoHasher("sha256")
|
|
85
|
+
.update(`${namespace}\0${value}`)
|
|
86
|
+
.digest("hex")
|
|
87
|
+
.slice(0, 12);
|
|
88
|
+
return `${namespace}_${hash}`;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const defaultCanonicalizePath = (path: string): Promise<string> =>
|
|
92
|
+
realpath(path);
|
|
93
|
+
|
|
94
|
+
const isDirectoryOrFile = async (path: string): Promise<boolean> => {
|
|
95
|
+
try {
|
|
96
|
+
await stat(path);
|
|
97
|
+
return true;
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const defaultDiscoverRepositoryRoot = async (
|
|
104
|
+
startingPath: string
|
|
105
|
+
): Promise<string | null> => {
|
|
106
|
+
let current = startingPath;
|
|
107
|
+
while (true) {
|
|
108
|
+
if (await isDirectoryOrFile(join(current, ".git"))) {
|
|
109
|
+
return current;
|
|
110
|
+
}
|
|
111
|
+
const parent = dirname(current);
|
|
112
|
+
if (parent === current) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
current = parent;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const pathDistance = (parent: string, child: string): number => {
|
|
120
|
+
const nestedPath = relative(parent, child);
|
|
121
|
+
if (nestedPath === "") {
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
return nestedPath.split(sep).filter(Boolean).length;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const describeRelationship = (
|
|
128
|
+
collectionPath: string,
|
|
129
|
+
rootPath: string
|
|
130
|
+
): Pick<ProjectAffinityMatch, "distance" | "relation"> | null => {
|
|
131
|
+
if (collectionPath === rootPath) {
|
|
132
|
+
return { distance: 0, relation: "exact" };
|
|
133
|
+
}
|
|
134
|
+
if (isCanonicalPathContained(collectionPath, rootPath)) {
|
|
135
|
+
return {
|
|
136
|
+
distance: pathDistance(collectionPath, rootPath),
|
|
137
|
+
relation: "collection_contains_root",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (isCanonicalPathContained(rootPath, collectionPath)) {
|
|
141
|
+
return {
|
|
142
|
+
distance: pathDistance(rootPath, collectionPath),
|
|
143
|
+
relation: "root_contains_collection",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const canonicalizeCollections = async (
|
|
150
|
+
collections: readonly Collection[],
|
|
151
|
+
canonicalizePath: ProjectAffinityResolverDependencies["canonicalizePath"]
|
|
152
|
+
): Promise<CanonicalCollection[]> => {
|
|
153
|
+
const resolved = await Promise.all(
|
|
154
|
+
collections.map(async (collection): Promise<CanonicalCollection | null> => {
|
|
155
|
+
try {
|
|
156
|
+
const path = await canonicalizePath(collection.path);
|
|
157
|
+
return {
|
|
158
|
+
alias: alias("collection", collection.name),
|
|
159
|
+
name: collection.name,
|
|
160
|
+
path,
|
|
161
|
+
};
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
);
|
|
167
|
+
return resolved
|
|
168
|
+
.filter((collection): collection is CanonicalCollection =>
|
|
169
|
+
Boolean(collection)
|
|
170
|
+
)
|
|
171
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const canonicalizeTrustedRoot = async (
|
|
175
|
+
root: Extract<ProjectAffinityRoot, { path: string }>,
|
|
176
|
+
dependencies: ProjectAffinityResolverDependencies
|
|
177
|
+
): Promise<CanonicalTrustedRoot | null> => {
|
|
178
|
+
let canonicalPath: string;
|
|
179
|
+
try {
|
|
180
|
+
canonicalPath = await dependencies.canonicalizePath(root.path);
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let repositoryRootDiscovered = false;
|
|
186
|
+
if (root.source === "cli_cwd" || root.source === "cli_worktree") {
|
|
187
|
+
let discovered: string | null = null;
|
|
188
|
+
try {
|
|
189
|
+
discovered = await dependencies.discoverRepositoryRoot(canonicalPath);
|
|
190
|
+
} catch {
|
|
191
|
+
// Repository discovery is opportunistic; the trusted canonical cwd
|
|
192
|
+
// remains a valid affinity root when discovery is unavailable.
|
|
193
|
+
}
|
|
194
|
+
if (discovered) {
|
|
195
|
+
try {
|
|
196
|
+
canonicalPath = await dependencies.canonicalizePath(discovered);
|
|
197
|
+
repositoryRootDiscovered = true;
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
path: canonicalPath,
|
|
206
|
+
repositoryRootDiscovered,
|
|
207
|
+
rootAlias: alias("root", canonicalPath),
|
|
208
|
+
source: root.source,
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const zeroMetadata = (
|
|
213
|
+
root: ProjectAffinityRoot,
|
|
214
|
+
reason: ProjectAffinityZeroReason,
|
|
215
|
+
source: ProjectAffinityRoot["source"] = root.source
|
|
216
|
+
): ProjectAffinityRootMetadata => ({
|
|
217
|
+
collectionAliases: [],
|
|
218
|
+
reason,
|
|
219
|
+
repositoryRootDiscovered: false,
|
|
220
|
+
rootAlias: alias(
|
|
221
|
+
"root",
|
|
222
|
+
root.source === "remote_hint" ? root.hint : root.path
|
|
223
|
+
),
|
|
224
|
+
source,
|
|
225
|
+
status: "zero",
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const relationOrder: Record<ProjectAffinityRelation, number> = {
|
|
229
|
+
exact: 0,
|
|
230
|
+
collection_contains_root: 1,
|
|
231
|
+
root_contains_collection: 2,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const compareMatches = (
|
|
235
|
+
left: ProjectAffinityMatch,
|
|
236
|
+
right: ProjectAffinityMatch
|
|
237
|
+
): number =>
|
|
238
|
+
relationOrder[left.relation] - relationOrder[right.relation] ||
|
|
239
|
+
left.distance - right.distance ||
|
|
240
|
+
left.collection.localeCompare(right.collection) ||
|
|
241
|
+
left.rootAlias.localeCompare(right.rootAlias);
|
|
242
|
+
|
|
243
|
+
export async function resolveProjectAffinity(
|
|
244
|
+
input: ProjectAffinityInput,
|
|
245
|
+
collections: readonly Collection[],
|
|
246
|
+
context: ProjectAffinityResolverContext,
|
|
247
|
+
overrides: Partial<ProjectAffinityResolverDependencies> = {}
|
|
248
|
+
): Promise<ProjectAffinityResolution> {
|
|
249
|
+
if (context.channel === "remote") {
|
|
250
|
+
return {
|
|
251
|
+
matches: [],
|
|
252
|
+
roots: input.roots.map((root) =>
|
|
253
|
+
zeroMetadata(root, "untrusted_remote_hint", "remote_hint")
|
|
254
|
+
),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const dependencies: ProjectAffinityResolverDependencies = {
|
|
259
|
+
canonicalizePath: overrides.canonicalizePath ?? defaultCanonicalizePath,
|
|
260
|
+
discoverRepositoryRoot:
|
|
261
|
+
overrides.discoverRepositoryRoot ?? defaultDiscoverRepositoryRoot,
|
|
262
|
+
};
|
|
263
|
+
const trustedRoots = input.roots.filter(
|
|
264
|
+
(root): root is Extract<ProjectAffinityRoot, { path: string }> =>
|
|
265
|
+
root.source !== "remote_hint"
|
|
266
|
+
);
|
|
267
|
+
const canonicalCollections =
|
|
268
|
+
trustedRoots.length === 0
|
|
269
|
+
? []
|
|
270
|
+
: await canonicalizeCollections(
|
|
271
|
+
collections,
|
|
272
|
+
dependencies.canonicalizePath
|
|
273
|
+
);
|
|
274
|
+
const matches: ProjectAffinityMatch[] = [];
|
|
275
|
+
const roots: ProjectAffinityRootMetadata[] = [];
|
|
276
|
+
|
|
277
|
+
for (const root of input.roots) {
|
|
278
|
+
if (root.source === "remote_hint") {
|
|
279
|
+
roots.push(zeroMetadata(root, "untrusted_remote_hint"));
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const canonicalRoot = await canonicalizeTrustedRoot(root, dependencies);
|
|
284
|
+
if (!canonicalRoot) {
|
|
285
|
+
roots.push(zeroMetadata(root, "root_unavailable"));
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const rootMatches = canonicalCollections
|
|
290
|
+
.map((collection): ProjectAffinityMatch | null => {
|
|
291
|
+
const relationship = describeRelationship(
|
|
292
|
+
collection.path,
|
|
293
|
+
canonicalRoot.path
|
|
294
|
+
);
|
|
295
|
+
return relationship
|
|
296
|
+
? {
|
|
297
|
+
collection: collection.name,
|
|
298
|
+
collectionAlias: collection.alias,
|
|
299
|
+
rootAlias: canonicalRoot.rootAlias,
|
|
300
|
+
source: canonicalRoot.source,
|
|
301
|
+
...relationship,
|
|
302
|
+
}
|
|
303
|
+
: null;
|
|
304
|
+
})
|
|
305
|
+
.filter((match): match is ProjectAffinityMatch => Boolean(match))
|
|
306
|
+
.sort(compareMatches);
|
|
307
|
+
|
|
308
|
+
if (rootMatches.length === 0) {
|
|
309
|
+
roots.push({
|
|
310
|
+
...zeroMetadata(root, "no_collection_match"),
|
|
311
|
+
repositoryRootDiscovered: canonicalRoot.repositoryRootDiscovered,
|
|
312
|
+
rootAlias: canonicalRoot.rootAlias,
|
|
313
|
+
});
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
matches.push(...rootMatches);
|
|
318
|
+
roots.push({
|
|
319
|
+
collectionAliases: rootMatches.map((match) => match.collectionAlias),
|
|
320
|
+
reason: null,
|
|
321
|
+
repositoryRootDiscovered: canonicalRoot.repositoryRootDiscovered,
|
|
322
|
+
rootAlias: canonicalRoot.rootAlias,
|
|
323
|
+
source: root.source,
|
|
324
|
+
status: "matched",
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
matches.sort(compareMatches);
|
|
329
|
+
return { matches, roots };
|
|
330
|
+
}
|
package/src/core/validation.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { realpath } from "node:fs/promises";
|
|
|
9
9
|
// node:os for homedir (no Bun os utils)
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
// node:path for path utils (no Bun path utils)
|
|
12
|
-
import { isAbsolute, join, posix as pathPosix } from "node:path";
|
|
12
|
+
import { isAbsolute, join, posix as pathPosix, relative, sep } from "node:path";
|
|
13
13
|
|
|
14
14
|
import { toAbsolutePath } from "../config/paths";
|
|
15
15
|
|
|
@@ -58,6 +58,25 @@ export function validateRelPath(relPath: string): string {
|
|
|
58
58
|
return normalized;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Return whether candidate is equal to or nested beneath parent.
|
|
63
|
+
*
|
|
64
|
+
* Inputs must already be canonical absolute paths. `relative()` keeps this
|
|
65
|
+
* segment-safe, unlike string-prefix checks (`/project` vs `/project-old`).
|
|
66
|
+
*/
|
|
67
|
+
export function isCanonicalPathContained(
|
|
68
|
+
parent: string,
|
|
69
|
+
candidate: string
|
|
70
|
+
): boolean {
|
|
71
|
+
const relativePath = relative(parent, candidate);
|
|
72
|
+
return (
|
|
73
|
+
relativePath === "" ||
|
|
74
|
+
(relativePath !== ".." &&
|
|
75
|
+
!relativePath.startsWith(`..${sep}`) &&
|
|
76
|
+
!isAbsolute(relativePath))
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
61
80
|
export async function validateCollectionRoot(
|
|
62
81
|
inputPath: string
|
|
63
82
|
): Promise<string> {
|
package/src/mcp/tools/ask.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { ToolContext } from "../server";
|
|
|
8
8
|
import type { ToolResult } from "./index";
|
|
9
9
|
|
|
10
10
|
import { buildVerifiedAsk } from "../../app/verified-ask";
|
|
11
|
+
import { resolveRemoteProjectAffinity } from "../../core/project-affinity-surface";
|
|
11
12
|
import {
|
|
12
13
|
finishRetrievalTraceAfterError,
|
|
13
14
|
retrievalTraceFilters,
|
|
@@ -30,6 +31,7 @@ const queryModeSchema = z
|
|
|
30
31
|
export const askInputSchema = z
|
|
31
32
|
.object({
|
|
32
33
|
query: z.string().trim().min(1),
|
|
34
|
+
projectHints: z.array(z.string()).max(16).optional(),
|
|
33
35
|
verify: z.literal(true),
|
|
34
36
|
collection: z.string().optional(),
|
|
35
37
|
limit: z.number().int().min(1).max(100).default(5),
|
|
@@ -153,8 +155,14 @@ export const handleAsk = (
|
|
|
153
155
|
);
|
|
154
156
|
if (!normalized.ok) throw new Error(normalized.error.message);
|
|
155
157
|
const query = normalized.value.query;
|
|
158
|
+
const { projectHints, ...askInput } = args;
|
|
159
|
+
const projectAffinity = await resolveRemoteProjectAffinity(
|
|
160
|
+
context.config,
|
|
161
|
+
projectHints
|
|
162
|
+
);
|
|
156
163
|
const options = {
|
|
157
|
-
...
|
|
164
|
+
...askInput,
|
|
165
|
+
projectAffinity,
|
|
158
166
|
queryModes:
|
|
159
167
|
normalized.value.queryModes.length > 0
|
|
160
168
|
? normalized.value.queryModes
|
|
@@ -210,6 +218,7 @@ export const handleAsk = (
|
|
|
210
218
|
embedPort: modelPorts.embedPort,
|
|
211
219
|
rerankPort: modelPorts.rerankPort,
|
|
212
220
|
genPort: modelPorts.genPort,
|
|
221
|
+
projectAffinity,
|
|
213
222
|
traceSession,
|
|
214
223
|
});
|
|
215
224
|
const finished = await traceSession?.finish(
|
package/src/mcp/tools/context.ts
CHANGED
|
@@ -25,6 +25,11 @@ import {
|
|
|
25
25
|
parseContextVerifySurfaceInput,
|
|
26
26
|
} from "../../app/context-surface";
|
|
27
27
|
import { createNonTtyProgressRenderer } from "../../cli/progress";
|
|
28
|
+
import { ContextCapsuleContractError } from "../../core/context-capsule";
|
|
29
|
+
import {
|
|
30
|
+
ProjectAffinityInputError,
|
|
31
|
+
resolveRemoteProjectAffinity,
|
|
32
|
+
} from "../../core/project-affinity-surface";
|
|
28
33
|
import {
|
|
29
34
|
finishRetrievalTraceAfterError,
|
|
30
35
|
startRetrievalTraceRequest,
|
|
@@ -224,6 +229,18 @@ export const handleContext = (
|
|
|
224
229
|
null;
|
|
225
230
|
let traceSession: RetrievalTraceSession | undefined;
|
|
226
231
|
try {
|
|
232
|
+
let projectAffinity;
|
|
233
|
+
try {
|
|
234
|
+
projectAffinity = await resolveRemoteProjectAffinity(
|
|
235
|
+
context.config,
|
|
236
|
+
parsed.projectHints
|
|
237
|
+
);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (error instanceof ProjectAffinityInputError) {
|
|
240
|
+
throw new ContextCapsuleContractError("invalid_input", error.message);
|
|
241
|
+
}
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
227
244
|
const traceStart = await startRetrievalTraceRequest({
|
|
228
245
|
store: context.store,
|
|
229
246
|
config: context.config,
|
|
@@ -261,6 +278,7 @@ export const handleContext = (
|
|
|
261
278
|
vectorIndex: modelPorts?.vectorIndex ?? null,
|
|
262
279
|
embedPort: modelPorts?.embedPort ?? null,
|
|
263
280
|
rerankPort: modelPorts?.rerankPort ?? null,
|
|
281
|
+
projectAffinity,
|
|
264
282
|
traceSession,
|
|
265
283
|
});
|
|
266
284
|
const finalized = await traceSession?.finish("completed");
|
package/src/mcp/tools/index.ts
CHANGED
|
@@ -131,13 +131,22 @@ export const MCP_WRITE_TOOL_NAMES = new Set([
|
|
|
131
131
|
// Shared Input Schemas
|
|
132
132
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
133
133
|
|
|
134
|
-
const
|
|
134
|
+
const projectHintsInputSchema = z
|
|
135
|
+
.array(z.string())
|
|
136
|
+
.max(16)
|
|
137
|
+
.optional()
|
|
138
|
+
.describe(
|
|
139
|
+
"Opaque caller project hints for cross-surface parity; remote hints never inspect server paths"
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
export const searchInputSchema = z.object({
|
|
135
143
|
query: z
|
|
136
144
|
.string()
|
|
137
145
|
.min(1, "Query cannot be empty")
|
|
138
146
|
.describe(
|
|
139
147
|
"Exact keyword, identifier, filename, error text, or phrase to match with BM25"
|
|
140
148
|
),
|
|
149
|
+
projectHints: projectHintsInputSchema,
|
|
141
150
|
collection: z
|
|
142
151
|
.string()
|
|
143
152
|
.optional()
|
|
@@ -378,13 +387,14 @@ const duplicateNoteInputSchema = z.object({
|
|
|
378
387
|
name: z.string().optional(),
|
|
379
388
|
});
|
|
380
389
|
|
|
381
|
-
const vsearchInputSchema = z.object({
|
|
390
|
+
export const vsearchInputSchema = z.object({
|
|
382
391
|
query: z
|
|
383
392
|
.string()
|
|
384
393
|
.min(1, "Query cannot be empty")
|
|
385
394
|
.describe(
|
|
386
395
|
"Natural-language concept to match semantically; use gno_search for exact error text or identifiers"
|
|
387
396
|
),
|
|
397
|
+
projectHints: projectHintsInputSchema,
|
|
388
398
|
collection: z
|
|
389
399
|
.string()
|
|
390
400
|
.optional()
|
|
@@ -455,6 +465,7 @@ export const queryInputSchema = z.object({
|
|
|
455
465
|
.describe(
|
|
456
466
|
"Primary user query; combine with intent or queryModes for ambiguous requests"
|
|
457
467
|
),
|
|
468
|
+
projectHints: projectHintsInputSchema,
|
|
458
469
|
collection: z
|
|
459
470
|
.string()
|
|
460
471
|
.optional()
|