@gmickel/gno 2.12.2 → 2.14.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 +1 -1
- package/assets/skill/SKILL.md +36 -5
- package/assets/skill/cli-reference.md +62 -11
- package/assets/skill/examples.md +30 -4
- package/assets/skill/mcp-reference.md +16 -0
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.12.2.zip → gno-browser-clipper-v2.14.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.14.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +139 -16
- package/spec/mcp.md +111 -3
- package/spec/output-schemas/collection-list.schema.json +28 -0
- package/spec/output-schemas/convert.schema.json +97 -0
- package/spec/output-schemas/ls.schema.json +65 -0
- package/spec/output-schemas/mcp-job-status.schema.json +17 -1
- package/spec/output-schemas/mcp-sync-result.schema.json +9 -2
- package/spec/output-schemas/search-results.schema.json +7 -0
- package/src/cli/commands/index-cmd.ts +50 -12
- package/src/cli/commands/ls.ts +20 -3
- package/src/cli/commands/profile-apply.ts +2 -1
- package/src/cli/commands/query.ts +21 -8
- package/src/cli/commands/search.ts +11 -0
- package/src/cli/commands/vsearch.ts +17 -4
- package/src/cli/options.ts +19 -0
- package/src/cli/program.ts +90 -10
- package/src/config/loader.ts +11 -1
- package/src/config/types.ts +90 -1
- package/src/core/audit-collection-rules.ts +331 -0
- package/src/core/audit-links.ts +3 -1
- package/src/core/audit-workspace.ts +69 -5
- package/src/core/collection-egress-policy-service.ts +4 -1
- package/src/core/collection-scope.ts +105 -0
- package/src/core/egress-enforcement.ts +20 -4
- package/src/core/egress-policy.ts +4 -0
- package/src/core/egress-provenance.ts +33 -11
- package/src/core/errors.ts +5 -0
- package/src/core/job-manager.ts +238 -19
- package/src/core/project-profile-apply-state.ts +6 -15
- package/src/core/project-profile-apply.ts +32 -1
- package/src/core/reindex-paths.ts +167 -0
- package/src/core/retrieval-trace-request.ts +1 -0
- package/src/ingestion/convert-file.ts +181 -0
- package/src/llm/registry.ts +20 -0
- package/src/mcp/http-egress.ts +21 -2
- package/src/mcp/tools/collection-scope.ts +25 -0
- package/src/mcp/tools/index-cmd.ts +66 -48
- package/src/mcp/tools/index.ts +74 -10
- package/src/mcp/tools/job-status.ts +7 -0
- package/src/mcp/tools/query.ts +28 -7
- package/src/mcp/tools/search.ts +4 -0
- package/src/mcp/tools/sync.ts +68 -60
- package/src/mcp/tools/vsearch.ts +9 -3
- package/src/pipeline/filters.ts +48 -5
- package/src/pipeline/graph-retrieval.ts +1 -0
- package/src/pipeline/hybrid.ts +15 -2
- package/src/pipeline/hydration.ts +7 -2
- package/src/pipeline/owner-fusion.ts +1 -0
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +7 -0
- package/src/pipeline/vsearch.ts +10 -5
- package/src/sdk/client.ts +45 -12
- package/src/sdk/convert.ts +27 -0
- package/src/sdk/documents.ts +15 -2
- package/src/sdk/index.ts +2 -0
- package/src/sdk/types.ts +4 -1
- package/src/serve/jobs.ts +4 -1
- package/src/serve/public/components/IndexingProgress.tsx +25 -3
- package/src/serve/routes/api.ts +164 -24
- package/src/store/sqlite/adapter.ts +97 -27
- package/src/store/sqlite/eligibility.ts +4 -0
- package/src/store/types.ts +7 -0
- package/src/store/vector/sqlite-vec.ts +67 -15
- package/src/store/vector/variant-search.ts +41 -4
- package/browser-extension/artifacts/gno-browser-clipper-v2.12.2.zip.sha256 +0 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate caller-supplied paths for a path-level reindex.
|
|
3
|
+
*
|
|
4
|
+
* @module src/core/reindex-paths
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// node:fs/promises realpath, lstat/stat (symlinks, directories) and node:path
|
|
8
|
+
// have no Bun equivalent.
|
|
9
|
+
import { lstat, readlink, realpath, stat } from "node:fs/promises";
|
|
10
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
11
|
+
|
|
12
|
+
import type { Collection } from "../config/types";
|
|
13
|
+
|
|
14
|
+
import { collectionToWalkConfig } from "../ingestion/types";
|
|
15
|
+
import { matchesWalkPath } from "../ingestion/walker";
|
|
16
|
+
import { MCP_ERRORS } from "./errors";
|
|
17
|
+
|
|
18
|
+
const escapes = (rel: string): boolean =>
|
|
19
|
+
rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
20
|
+
|
|
21
|
+
const toPosix = (rel: string): string => rel.split(sep).join("/");
|
|
22
|
+
|
|
23
|
+
const missing = (error: unknown): boolean =>
|
|
24
|
+
error !== null &&
|
|
25
|
+
typeof error === "object" &&
|
|
26
|
+
"code" in error &&
|
|
27
|
+
(error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
28
|
+
|
|
29
|
+
const MAX_SYMLINK_HOPS = 32;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Most paths one request may list, and most a queued job keeps after merging.
|
|
33
|
+
* Past it, reindexing the whole collection is the better call.
|
|
34
|
+
*/
|
|
35
|
+
export const MAX_REINDEX_PATHS = 256;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Realpath of `path`, or of its nearest existing ancestor below `root`. A
|
|
39
|
+
* dangling symlink on the way resolves to where it points, so a missing file
|
|
40
|
+
* cannot leave the root through a link whose target does not exist.
|
|
41
|
+
*/
|
|
42
|
+
async function nearestRealpath(
|
|
43
|
+
path: string,
|
|
44
|
+
root: string,
|
|
45
|
+
hops = 0
|
|
46
|
+
): Promise<string | null> {
|
|
47
|
+
let current = path;
|
|
48
|
+
for (;;) {
|
|
49
|
+
try {
|
|
50
|
+
return await realpath(current);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (!missing(error)) throw error;
|
|
53
|
+
}
|
|
54
|
+
const isLink = await lstat(current).then(
|
|
55
|
+
(stats) => stats.isSymbolicLink(),
|
|
56
|
+
() => false
|
|
57
|
+
);
|
|
58
|
+
if (isLink) {
|
|
59
|
+
const target = resolve(dirname(current), await readlink(current));
|
|
60
|
+
if (hops >= MAX_SYMLINK_HOPS) return target;
|
|
61
|
+
return (await nearestRealpath(target, root, hops + 1)) ?? target;
|
|
62
|
+
}
|
|
63
|
+
const parent = dirname(current);
|
|
64
|
+
if (parent === current || relative(root, parent) === "") return null;
|
|
65
|
+
current = parent;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve paths (relative to the collection root, or absolute inside it) to
|
|
71
|
+
* deduplicated collection-relative POSIX paths. A path that no longer exists
|
|
72
|
+
* is kept so the sync can mark it inactive. Throws `INVALID_INPUT: ...` for an
|
|
73
|
+
* empty list or more than `MAX_REINDEX_PATHS` paths, and `INVALID_PATH: ...`
|
|
74
|
+
* for a directory or a path outside the root, including one that leaves it
|
|
75
|
+
* through a symlink.
|
|
76
|
+
*/
|
|
77
|
+
export async function resolveReindexPaths(
|
|
78
|
+
collection: Collection,
|
|
79
|
+
paths: readonly unknown[]
|
|
80
|
+
): Promise<string[]> {
|
|
81
|
+
if (paths.length === 0) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`${MCP_ERRORS.INVALID_INPUT.code}: paths must list at least one path`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (paths.length > MAX_REINDEX_PATHS) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`${MCP_ERRORS.INVALID_INPUT.code}: paths lists ${paths.length} paths (at most ${MAX_REINDEX_PATHS}); reindex the whole collection instead`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const root = resolve(collection.path);
|
|
92
|
+
let realRoot: string;
|
|
93
|
+
try {
|
|
94
|
+
realRoot = await realpath(root);
|
|
95
|
+
} catch {
|
|
96
|
+
realRoot = root;
|
|
97
|
+
}
|
|
98
|
+
const resolved = new Set<string>();
|
|
99
|
+
for (const path of paths) {
|
|
100
|
+
if (typeof path !== "string" || path.trim() === "") {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`${MCP_ERRORS.INVALID_INPUT.code}: paths must be non-empty strings`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const absolute = resolve(root, path);
|
|
106
|
+
const rel = relative(root, absolute);
|
|
107
|
+
if (escapes(rel)) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`${MCP_ERRORS.INVALID_PATH.code}: ${path} is outside collection ${collection.name}`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
// A missing file is checked through its nearest existing folder, so a
|
|
113
|
+
// deletion path cannot leave the root through a symlinked parent.
|
|
114
|
+
const real = await nearestRealpath(absolute, root);
|
|
115
|
+
if (
|
|
116
|
+
real !== null &&
|
|
117
|
+
escapes(relative(realRoot, real)) &&
|
|
118
|
+
real !== realRoot
|
|
119
|
+
) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`${MCP_ERRORS.INVALID_PATH.code}: ${path} resolves outside collection ${collection.name}`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const isDirectory = await stat(absolute).then(
|
|
125
|
+
(stats) => stats.isDirectory(),
|
|
126
|
+
() => false
|
|
127
|
+
);
|
|
128
|
+
if (isDirectory) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`${MCP_ERRORS.INVALID_PATH.code}: ${path} is a directory; list the files in it, or reindex the whole collection`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
// A listed path obeys the collection's own pattern, include and exclude
|
|
134
|
+
// rules: an excluded file must not become searchable by naming it.
|
|
135
|
+
if (!matchesWalkPath(toPosix(rel), collectionToWalkConfig(collection, 0))) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`${MCP_ERRORS.INVALID_PATH.code}: ${path} is not part of collection ${collection.name} (its pattern, include or exclude rules leave it out)`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
resolved.add(toPosix(rel));
|
|
141
|
+
}
|
|
142
|
+
return [...resolved];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve an optional path list for a reindex request. Paths need a single
|
|
147
|
+
* collection and apply to a plain sync: git pull and update commands act on
|
|
148
|
+
* the whole collection, so they cannot be combined with paths.
|
|
149
|
+
*/
|
|
150
|
+
export async function resolveReindexScope(
|
|
151
|
+
collection: Collection | null,
|
|
152
|
+
paths: readonly unknown[] | undefined,
|
|
153
|
+
options: { gitPull?: boolean; runUpdateCmd?: boolean } = {}
|
|
154
|
+
): Promise<string[] | null> {
|
|
155
|
+
if (paths === undefined) return null;
|
|
156
|
+
if (!collection) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`${MCP_ERRORS.INVALID_INPUT.code}: paths require a collection`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (options.gitPull || options.runUpdateCmd) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`${MCP_ERRORS.INVALID_INPUT.code}: paths cannot be combined with gitPull or an update command`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return resolveReindexPaths(collection, paths);
|
|
167
|
+
}
|
|
@@ -117,6 +117,7 @@ export const retrievalTraceFilters = (
|
|
|
117
117
|
limit: options.limit,
|
|
118
118
|
minScore: options.minScore,
|
|
119
119
|
collection: options.collection,
|
|
120
|
+
collections: options.collections,
|
|
120
121
|
lang: options.lang,
|
|
121
122
|
full: options.full,
|
|
122
123
|
lineNumbers: options.lineNumbers,
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert one file to the Markdown GNO would index for it, with nothing
|
|
3
|
+
* persisted (`gno convert`, SDK `convertFile`).
|
|
4
|
+
*
|
|
5
|
+
* The file goes through the indexing path: extension MIME detection and the
|
|
6
|
+
* record-adapter check, then a sniffed MIME and `prepareFile` in the
|
|
7
|
+
* terminable file processor under the default time and memory budget. The
|
|
8
|
+
* Markdown and mirror hash therefore equal what `gno update` stores for the
|
|
9
|
+
* same bytes. No config, index, store or cache is read or written.
|
|
10
|
+
*
|
|
11
|
+
* @module src/ingestion/convert-file
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// node:fs/promises for stat (no Bun equivalent for file stats)
|
|
15
|
+
import { stat } from "node:fs/promises";
|
|
16
|
+
// node:path for basename/resolve (no Bun path utils)
|
|
17
|
+
import { basename, resolve } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { getDefaultMimeDetector } from "../converters/mime";
|
|
20
|
+
import { markdownConverter } from "../converters/native/markdown";
|
|
21
|
+
import { getDefaultPipeline } from "../converters/pipeline";
|
|
22
|
+
import { DEFAULT_LIMITS } from "../converters/types";
|
|
23
|
+
import {
|
|
24
|
+
defaultConversionMemoryMb,
|
|
25
|
+
prepareInProcessor,
|
|
26
|
+
} from "./file-processor";
|
|
27
|
+
import { DEFAULT_CHUNK_PARAMS } from "./types";
|
|
28
|
+
|
|
29
|
+
export const CONVERT_SCHEMA_VERSION = "convert@1.0";
|
|
30
|
+
|
|
31
|
+
const BYTES_PER_MB = 1_048_576;
|
|
32
|
+
const SNIFF_BYTES = 512;
|
|
33
|
+
|
|
34
|
+
export interface ConvertFileResult {
|
|
35
|
+
schemaVersion: typeof CONVERT_SCHEMA_VERSION;
|
|
36
|
+
source: { path: string; mime: string; ext: string; sizeBytes: number };
|
|
37
|
+
converter: { id: string; version: string };
|
|
38
|
+
title: string | null;
|
|
39
|
+
languageHint: string | null;
|
|
40
|
+
markdown: string;
|
|
41
|
+
mirrorHash: string;
|
|
42
|
+
warnings: { code: string; message: string }[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Why a file was not converted. `validation` failures (missing file, over
|
|
47
|
+
* the size limit, no converter, multi-record format) exit 1 on the CLI;
|
|
48
|
+
* converter and processor failures exit 2.
|
|
49
|
+
*/
|
|
50
|
+
export interface ConvertFileFailure {
|
|
51
|
+
reason: string;
|
|
52
|
+
message: string;
|
|
53
|
+
validation: boolean;
|
|
54
|
+
details?: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ConvertFileOutcome =
|
|
58
|
+
| { ok: true; value: ConvertFileResult }
|
|
59
|
+
| { ok: false; error: ConvertFileFailure };
|
|
60
|
+
|
|
61
|
+
const fail = (
|
|
62
|
+
reason: string,
|
|
63
|
+
message: string,
|
|
64
|
+
validation: boolean,
|
|
65
|
+
details?: Record<string, unknown>
|
|
66
|
+
): ConvertFileOutcome => ({
|
|
67
|
+
ok: false,
|
|
68
|
+
error: { reason, message, validation, ...(details ? { details } : {}) },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** Convert one file; see the module comment. */
|
|
72
|
+
export async function convertFile(path: string): Promise<ConvertFileOutcome> {
|
|
73
|
+
const absPath = resolve(path);
|
|
74
|
+
let sizeBytes: number;
|
|
75
|
+
try {
|
|
76
|
+
// stat follows symlinks: a link to a regular file converts, a link to
|
|
77
|
+
// a directory does not.
|
|
78
|
+
const stats = await stat(absPath);
|
|
79
|
+
if (!stats.isFile()) {
|
|
80
|
+
return fail("NOT_FOUND", `Not a regular file: ${path}`, true);
|
|
81
|
+
}
|
|
82
|
+
sizeBytes = stats.size;
|
|
83
|
+
} catch {
|
|
84
|
+
return fail("NOT_FOUND", `File not found: ${path}`, true);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Refused before reading, so an oversized file is never loaded.
|
|
88
|
+
if (sizeBytes > DEFAULT_LIMITS.maxBytes) {
|
|
89
|
+
return fail(
|
|
90
|
+
"TOO_LARGE",
|
|
91
|
+
`File size ${sizeBytes} exceeds the indexing limit ${DEFAULT_LIMITS.maxBytes}`,
|
|
92
|
+
true,
|
|
93
|
+
{ sizeBytes, limitBytes: DEFAULT_LIMITS.maxBytes }
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const detector = getDefaultMimeDetector();
|
|
98
|
+
const byExtension = detector.detect(absPath, new Uint8Array());
|
|
99
|
+
const recordAdapter = await getDefaultPipeline().selectRecordAdapter(
|
|
100
|
+
byExtension.mime,
|
|
101
|
+
byExtension.ext
|
|
102
|
+
);
|
|
103
|
+
if (recordAdapter) {
|
|
104
|
+
return fail(
|
|
105
|
+
"MULTI_DOCUMENT",
|
|
106
|
+
`${recordAdapter.id} files hold several records; index them in a collection instead`,
|
|
107
|
+
true,
|
|
108
|
+
{ format: recordAdapter.id }
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let bytes: Uint8Array;
|
|
113
|
+
try {
|
|
114
|
+
bytes = await Bun.file(absPath).bytes();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// Readable at stat but not at read: removed meanwhile, or no permission.
|
|
117
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
118
|
+
if (code === "ENOENT") {
|
|
119
|
+
return fail("NOT_FOUND", `File not found: ${path}`, true);
|
|
120
|
+
}
|
|
121
|
+
const denied = code === "EACCES" || code === "EPERM";
|
|
122
|
+
return fail(
|
|
123
|
+
denied ? "PERMISSION" : "IO",
|
|
124
|
+
`Cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
125
|
+
false
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const { mime, ext } = detector.detect(
|
|
129
|
+
absPath,
|
|
130
|
+
bytes.subarray(0, SNIFF_BYTES)
|
|
131
|
+
);
|
|
132
|
+
const prepared = await prepareInProcessor(
|
|
133
|
+
{
|
|
134
|
+
input: {
|
|
135
|
+
sourcePath: absPath,
|
|
136
|
+
relativePath: basename(absPath),
|
|
137
|
+
collection: "",
|
|
138
|
+
bytes,
|
|
139
|
+
mime,
|
|
140
|
+
ext,
|
|
141
|
+
limits: { ...DEFAULT_LIMITS },
|
|
142
|
+
},
|
|
143
|
+
metadataExt: ext,
|
|
144
|
+
contentTypeRules: [],
|
|
145
|
+
chunkParams: DEFAULT_CHUNK_PARAMS,
|
|
146
|
+
memoryManaged: false,
|
|
147
|
+
markdownSource: markdownConverter.canHandle(mime, ext),
|
|
148
|
+
previous: null,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
timeoutMs: DEFAULT_LIMITS.timeoutMs,
|
|
152
|
+
maxMemoryBytes: defaultConversionMemoryMb() * BYTES_PER_MB,
|
|
153
|
+
},
|
|
154
|
+
{ onPrevious: () => undefined }
|
|
155
|
+
);
|
|
156
|
+
if (!prepared.ok) {
|
|
157
|
+
const { code, message, details } = prepared.error;
|
|
158
|
+
return fail(code, message, code === "UNSUPPORTED", details);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const { artifact } = prepared.value;
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
value: {
|
|
165
|
+
schemaVersion: CONVERT_SCHEMA_VERSION,
|
|
166
|
+
source: { path: absPath, mime, ext, sizeBytes },
|
|
167
|
+
converter: {
|
|
168
|
+
id: artifact.meta.converterId,
|
|
169
|
+
version: artifact.meta.converterVersion,
|
|
170
|
+
},
|
|
171
|
+
title: artifact.title ?? null,
|
|
172
|
+
languageHint: artifact.languageHint ?? null,
|
|
173
|
+
markdown: artifact.markdown,
|
|
174
|
+
mirrorHash: artifact.mirrorHash,
|
|
175
|
+
warnings: (artifact.meta.warnings ?? []).map(({ code, message }) => ({
|
|
176
|
+
code,
|
|
177
|
+
message,
|
|
178
|
+
})),
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
package/src/llm/registry.ts
CHANGED
|
@@ -134,6 +134,26 @@ export function resolveModelUri(
|
|
|
134
134
|
return preset[type];
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Collection to resolve models for when a request lists several collections:
|
|
139
|
+
* the first listed one when every listed collection resolves `type` to the
|
|
140
|
+
* same model, otherwise undefined (the unscoped resolution).
|
|
141
|
+
*/
|
|
142
|
+
export function modelCollectionForList(
|
|
143
|
+
config: Config,
|
|
144
|
+
type: ModelType,
|
|
145
|
+
collections: readonly string[] | undefined
|
|
146
|
+
): string | undefined {
|
|
147
|
+
const [first] = collections ?? [];
|
|
148
|
+
if (first === undefined) return undefined;
|
|
149
|
+
const uri = resolveModelUri(config, type, undefined, first);
|
|
150
|
+
return collections?.every(
|
|
151
|
+
(collection) => resolveModelUri(config, type, undefined, collection) === uri
|
|
152
|
+
)
|
|
153
|
+
? first
|
|
154
|
+
: undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
137
157
|
export function resolveModelSource(
|
|
138
158
|
config: Config,
|
|
139
159
|
type: ModelType,
|
package/src/mcp/http-egress.ts
CHANGED
|
@@ -140,6 +140,19 @@ const GRAPH_RESULT_TOOLS = new Set([
|
|
|
140
140
|
"gno_impact",
|
|
141
141
|
]);
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Tools whose `collections` argument bounds the collections they read. The
|
|
145
|
+
* retrieval tools reject `collection` with `collections`, so the union of both
|
|
146
|
+
* is never wider than what the handler serves.
|
|
147
|
+
*/
|
|
148
|
+
const COLLECTIONS_SCOPE_TOOLS = new Set([
|
|
149
|
+
"gno_audit",
|
|
150
|
+
"gno_impact",
|
|
151
|
+
"gno_query",
|
|
152
|
+
"gno_search",
|
|
153
|
+
"gno_vsearch",
|
|
154
|
+
]);
|
|
155
|
+
|
|
143
156
|
const requestedCollections = (
|
|
144
157
|
params: unknown,
|
|
145
158
|
collections: readonly Collection[]
|
|
@@ -155,7 +168,8 @@ const requestedCollections = (
|
|
|
155
168
|
if (typeof direct === "string" && direct.trim())
|
|
156
169
|
names.add(direct.trim().toLowerCase());
|
|
157
170
|
if (
|
|
158
|
-
|
|
171
|
+
typeof record?.name === "string" &&
|
|
172
|
+
COLLECTIONS_SCOPE_TOOLS.has(record.name) &&
|
|
159
173
|
Array.isArray(args.collections)
|
|
160
174
|
) {
|
|
161
175
|
for (const value of args.collections) {
|
|
@@ -192,7 +206,12 @@ const requestedCollections = (
|
|
|
192
206
|
if (collection) names.add(collection);
|
|
193
207
|
}
|
|
194
208
|
}
|
|
195
|
-
|
|
209
|
+
// A name that is not configured serves nothing (the handler refuses it), so
|
|
210
|
+
// it never narrows or widens the check; with no configured name left, every
|
|
211
|
+
// collection is checked (fail closed).
|
|
212
|
+
const configured = new Set(collections.map(({ name }) => name.toLowerCase()));
|
|
213
|
+
const scoped = [...names].filter((name) => configured.has(name));
|
|
214
|
+
return scoped.length > 0 ? scoped : collections.map(({ name }) => name);
|
|
196
215
|
};
|
|
197
216
|
|
|
198
217
|
const enforceMessage = (
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP adapter for the shared request collection allowlist.
|
|
3
|
+
*
|
|
4
|
+
* @module src/mcp/tools/collection-scope
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ToolContext } from "../server";
|
|
8
|
+
|
|
9
|
+
import { resolveCollectionScope } from "../../core/collection-scope";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve `collections` for gno_search, gno_vsearch and gno_query. Throws a
|
|
13
|
+
* `VALIDATION:` error for malformed input or `collection` plus `collections`,
|
|
14
|
+
* and the existing unknown-collection error for names not configured.
|
|
15
|
+
*/
|
|
16
|
+
export function requestCollections(
|
|
17
|
+
args: { collection?: string; collections?: string[] },
|
|
18
|
+
ctx: Pick<ToolContext, "collections">
|
|
19
|
+
): string[] | undefined {
|
|
20
|
+
const scope = resolveCollectionScope(args, ctx.collections);
|
|
21
|
+
if (scope.ok) return scope.collections;
|
|
22
|
+
throw new Error(
|
|
23
|
+
scope.kind === "validation" ? `VALIDATION: ${scope.message}` : scope.message
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -8,12 +8,12 @@ import type { Collection } from "../../config/types";
|
|
|
8
8
|
import type { ToolContext } from "../server";
|
|
9
9
|
|
|
10
10
|
import { MCP_ERRORS } from "../../core/errors";
|
|
11
|
-
import { acquireWriteLock, type WriteLockHandle } from "../../core/file-lock";
|
|
12
11
|
import { JobError } from "../../core/job-manager";
|
|
13
12
|
import {
|
|
14
13
|
recordContentMutation,
|
|
15
14
|
recordIndexMutation,
|
|
16
15
|
} from "../../core/mutation-generations";
|
|
16
|
+
import { resolveReindexScope } from "../../core/reindex-paths";
|
|
17
17
|
import { normalizeCollectionName } from "../../core/validation";
|
|
18
18
|
import { embedBacklog } from "../../embed";
|
|
19
19
|
import { defaultSyncService, withContentTypeRules } from "../../ingestion";
|
|
@@ -29,12 +29,14 @@ import { runTool, type ToolResult } from "./index";
|
|
|
29
29
|
interface IndexInput {
|
|
30
30
|
collection?: string;
|
|
31
31
|
gitPull?: boolean;
|
|
32
|
+
paths?: string[];
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
interface IndexResultOutput {
|
|
35
36
|
jobId: string;
|
|
36
|
-
status: "started";
|
|
37
|
+
status: "started" | "queued";
|
|
37
38
|
collections: string[];
|
|
39
|
+
paths?: string[];
|
|
38
40
|
phases: ["sync", "embed"];
|
|
39
41
|
options: {
|
|
40
42
|
gitPull: boolean;
|
|
@@ -47,6 +49,7 @@ function formatIndexResult(result: IndexResultOutput): string {
|
|
|
47
49
|
lines.push(`Job: ${result.jobId}`);
|
|
48
50
|
lines.push(`Status: ${result.status}`);
|
|
49
51
|
lines.push(`Collections: ${result.collections.join(", ") || "(none)"}`);
|
|
52
|
+
if (result.paths) lines.push(`Paths: ${result.paths.join(", ")}`);
|
|
50
53
|
lines.push(`Phases: ${result.phases.join(" → ")}`);
|
|
51
54
|
lines.push(`Git pull: ${result.options.gitPull ? "yes" : "no"}`);
|
|
52
55
|
return lines.join("\n");
|
|
@@ -74,17 +77,7 @@ export function handleIndex(
|
|
|
74
77
|
throw new Error("Write tools disabled. Start MCP with --enable-write.");
|
|
75
78
|
}
|
|
76
79
|
|
|
77
|
-
let lock: WriteLockHandle | null = null;
|
|
78
|
-
let handedOff = false;
|
|
79
|
-
|
|
80
80
|
try {
|
|
81
|
-
lock = await acquireWriteLock(ctx.writeLockPath);
|
|
82
|
-
if (!lock) {
|
|
83
|
-
throw new Error(
|
|
84
|
-
`${MCP_ERRORS.LOCKED.code}: ${MCP_ERRORS.LOCKED.message}`
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
81
|
const requestedCollection = args.collection?.trim();
|
|
89
82
|
const collection = requestedCollection
|
|
90
83
|
? resolveCollection(requestedCollection, ctx.collections)
|
|
@@ -108,47 +101,77 @@ export function handleIndex(
|
|
|
108
101
|
},
|
|
109
102
|
ctx.config
|
|
110
103
|
);
|
|
104
|
+
const paths = await resolveReindexScope(
|
|
105
|
+
collection,
|
|
106
|
+
args.paths,
|
|
107
|
+
options
|
|
108
|
+
);
|
|
111
109
|
enforceSyncCommandEgress(ctx, {
|
|
112
110
|
collectionNames: collections,
|
|
113
111
|
gitPull: options.gitPull,
|
|
114
112
|
runUpdateCmd: options.runUpdateCmd,
|
|
115
113
|
});
|
|
116
114
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
115
|
+
// Captured now: a queued pass runs after this call's context is gone.
|
|
116
|
+
const config = ctx.config;
|
|
117
|
+
const allCollections = ctx.collections;
|
|
118
|
+
const accepted = await ctx.jobManager.submitReindex({
|
|
119
|
+
type: "index",
|
|
120
|
+
collection: collection?.name ?? null,
|
|
121
|
+
paths,
|
|
122
|
+
options: {
|
|
123
|
+
gitPull: options.gitPull ?? false,
|
|
124
|
+
runUpdateCmd: options.runUpdateCmd ?? false,
|
|
125
|
+
},
|
|
126
|
+
run: async (scope) => {
|
|
128
127
|
const lease = ctx.acquireModelLease?.();
|
|
128
|
+
const passOptions = withContentTypeRules(
|
|
129
|
+
{ ...scope.options },
|
|
130
|
+
config
|
|
131
|
+
);
|
|
129
132
|
try {
|
|
130
133
|
// Phase 1: Sync
|
|
131
134
|
const syncResult = collection
|
|
132
|
-
? await
|
|
133
|
-
.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
135
|
+
? await (
|
|
136
|
+
scope.paths
|
|
137
|
+
? defaultSyncService.syncPaths(
|
|
138
|
+
collection,
|
|
139
|
+
ctx.store,
|
|
140
|
+
scope.paths,
|
|
141
|
+
passOptions
|
|
142
|
+
)
|
|
143
|
+
: defaultSyncService.syncCollection(
|
|
144
|
+
collection,
|
|
145
|
+
ctx.store,
|
|
146
|
+
passOptions
|
|
147
|
+
)
|
|
148
|
+
).then((r) => ({
|
|
149
|
+
collections: [r],
|
|
150
|
+
totalDurationMs: r.durationMs,
|
|
151
|
+
totalFilesProcessed: r.filesProcessed,
|
|
152
|
+
totalFilesAdded: r.filesAdded,
|
|
153
|
+
totalFilesUpdated: r.filesUpdated,
|
|
154
|
+
totalFilesErrored: r.filesErrored,
|
|
155
|
+
totalFilesSkipped: r.filesSkipped,
|
|
156
|
+
}))
|
|
143
157
|
: await defaultSyncService.syncAll(
|
|
144
|
-
|
|
158
|
+
allCollections,
|
|
145
159
|
ctx.store,
|
|
146
|
-
|
|
160
|
+
passOptions
|
|
147
161
|
);
|
|
148
162
|
recordContentMutation(syncResult, ctx.markContentMutation);
|
|
149
163
|
|
|
150
|
-
// Phase 2: Embed
|
|
151
|
-
|
|
164
|
+
// Phase 2: Embed. The model is looked up when the pass runs: a
|
|
165
|
+
// queued pass outlives a server reload, which disposes the
|
|
166
|
+
// adapter that was current at submit.
|
|
167
|
+
const liveConfig = ctx.config;
|
|
168
|
+
const modelUri = resolveModelUri(
|
|
169
|
+
liveConfig,
|
|
170
|
+
"embed",
|
|
171
|
+
undefined,
|
|
172
|
+
collection?.name
|
|
173
|
+
);
|
|
174
|
+
const llm = ctx.getModelAdapter?.() ?? new LlmAdapter(liveConfig);
|
|
152
175
|
const embedResult = await llm.createEmbeddingPort(modelUri, {
|
|
153
176
|
egressCollections: collections,
|
|
154
177
|
policy: { offline: true, allowDownload: false },
|
|
@@ -217,15 +240,14 @@ export function handleIndex(
|
|
|
217
240
|
} finally {
|
|
218
241
|
lease?.release();
|
|
219
242
|
}
|
|
220
|
-
}
|
|
221
|
-
);
|
|
222
|
-
|
|
223
|
-
handedOff = true;
|
|
243
|
+
},
|
|
244
|
+
});
|
|
224
245
|
|
|
225
246
|
const result: IndexResultOutput = {
|
|
226
|
-
jobId,
|
|
247
|
+
jobId: accepted.jobId,
|
|
227
248
|
collections,
|
|
228
|
-
status: "started",
|
|
249
|
+
status: accepted.status === "running" ? "started" : "queued",
|
|
250
|
+
...(paths ? { paths } : {}),
|
|
229
251
|
phases: ["sync", "embed"],
|
|
230
252
|
options: {
|
|
231
253
|
gitPull: options.gitPull ?? false,
|
|
@@ -239,10 +261,6 @@ export function handleIndex(
|
|
|
239
261
|
throw new Error(`${error.code}: ${error.message}`);
|
|
240
262
|
}
|
|
241
263
|
throw error;
|
|
242
|
-
} finally {
|
|
243
|
-
if (lock && !handedOff) {
|
|
244
|
-
await lock.release();
|
|
245
|
-
}
|
|
246
264
|
}
|
|
247
265
|
},
|
|
248
266
|
formatIndexResult
|