@gmickel/gno 1.24.0 → 1.26.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 +18 -4
- package/assets/skill/SKILL.md +40 -17
- package/assets/skill/recipes/capture-and-file.md +20 -5
- package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip.sha256 +1 -0
- package/browser-extension/dist/PRIVACY.md +55 -0
- package/browser-extension/dist/chunk-vn5f663b.js +50 -0
- package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
- package/browser-extension/dist/content.js +1 -0
- package/browser-extension/dist/manifest.json +25 -0
- package/browser-extension/dist/preview.html +13 -0
- package/browser-extension/dist/service-worker.js +40 -0
- package/package.json +13 -3
- package/spec/cli.md +196 -4
- package/spec/db/schema.sql +102 -1
- package/spec/mcp.md +10 -0
- package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
- package/spec/output-schemas/browser-clip.schema.json +586 -0
- package/spec/output-schemas/capture-receipt.schema.json +22 -1
- package/spec/output-schemas/clipper-csrf.schema.json +12 -0
- package/spec/output-schemas/clipper-error.schema.json +46 -0
- package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
- package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
- package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
- package/spec/output-schemas/clipper-revoke.schema.json +28 -0
- package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
- package/spec/output-schemas/project-profile-apply.schema.json +209 -0
- package/spec/output-schemas/project-profile-command.schema.json +160 -0
- package/spec/output-schemas/query-diagnose.schema.json +7 -1
- package/spec/output-schemas/setup-profile-result.schema.json +87 -0
- package/spec/project-profile.schema.json +301 -0
- package/src/cli/commands/collection/add.ts +39 -45
- package/src/cli/commands/collection/remove.ts +28 -28
- package/src/cli/commands/collection/rename.ts +55 -73
- package/src/cli/commands/context/add.ts +37 -26
- package/src/cli/commands/context/rm.ts +47 -20
- package/src/cli/commands/init.ts +55 -125
- package/src/cli/commands/models/use.ts +43 -38
- package/src/cli/commands/profile-apply.ts +334 -0
- package/src/cli/commands/profile.ts +409 -0
- package/src/cli/commands/setup-activation.ts +205 -54
- package/src/cli/commands/setup-profile.ts +223 -0
- package/src/cli/commands/setup.ts +3 -0
- package/src/cli/program.ts +110 -9
- package/src/config/index.ts +3 -0
- package/src/config/project-profile.ts +367 -0
- package/src/config/saver.ts +16 -7
- package/src/config/types.ts +42 -0
- package/src/core/browser-clip-provenance.ts +139 -0
- package/src/core/browser-clip.ts +473 -0
- package/src/core/capture-write.ts +5 -0
- package/src/core/capture.ts +75 -18
- package/src/core/config-mutation.ts +138 -76
- package/src/core/config-write-lock.ts +89 -0
- package/src/core/context-identity.ts +16 -0
- package/src/core/context-resolver.ts +2 -12
- package/src/core/file-lock.ts +20 -6
- package/src/core/folder-setup-planning.ts +6 -21
- package/src/core/folder-setup.ts +30 -2
- package/src/core/path-rules.ts +53 -0
- package/src/core/project-affinity-surface.ts +102 -7
- package/src/core/project-profile-apply-state.ts +268 -0
- package/src/core/project-profile-apply-validation.ts +95 -0
- package/src/core/project-profile-apply.ts +408 -0
- package/src/core/project-profile-canonical.ts +71 -0
- package/src/core/project-profile-diff.ts +302 -0
- package/src/core/project-profile-discovery.ts +519 -0
- package/src/core/project-profile-file.ts +37 -0
- package/src/core/project-profile-parser.ts +98 -0
- package/src/core/project-profile.ts +490 -0
- package/src/ingestion/walker.ts +85 -44
- package/src/llm/cache.ts +21 -0
- package/src/serve/capture-service.ts +420 -0
- package/src/serve/clipper-body.ts +62 -0
- package/src/serve/clipper-capture.ts +248 -0
- package/src/serve/clipper-contract.ts +57 -0
- package/src/serve/clipper-idempotency.ts +35 -0
- package/src/serve/clipper-pairing.ts +297 -0
- package/src/serve/clipper-security-errors.ts +23 -0
- package/src/serve/clipper-security.ts +449 -0
- package/src/serve/config-sync.ts +2 -2
- package/src/serve/public/app.tsx +8 -1
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/index.html +1 -0
- package/src/serve/public/lib/clipper-approval.ts +206 -0
- package/src/serve/public/pages/ClipperPairing.tsx +210 -0
- package/src/serve/resident-runtime.ts +1 -0
- package/src/serve/routes/api.ts +20 -115
- package/src/serve/routes/clipper.ts +394 -0
- package/src/serve/server.ts +22 -0
- package/src/store/migrations/020-browser-clipper-security.ts +128 -0
- package/src/store/migrations/021-multi-context-identity.ts +37 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +83 -0
- package/src/store/sqlite/clipper-store-types.ts +104 -0
- package/src/store/sqlite/clipper-store.ts +496 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
// node:fs/promises provides realpath/stat; Bun has no equivalent for symlink-safe path identity and regular-file metadata.
|
|
2
|
+
import { realpath, stat } from "node:fs/promises";
|
|
3
|
+
// node:path provides cross-platform path operations; Bun has no path utilities.
|
|
4
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { Config, ModelPreset } from "../config/types";
|
|
7
|
+
|
|
8
|
+
import { normalizeContentTypes } from "../config/content-types";
|
|
9
|
+
import { createDefaultConfig } from "../config/defaults";
|
|
10
|
+
import {
|
|
11
|
+
PROJECT_PROFILE_FORCED_EXCLUDES,
|
|
12
|
+
PROJECT_PROFILE_SCHEMA_VERSION,
|
|
13
|
+
type ProjectProfile,
|
|
14
|
+
} from "../config/project-profile";
|
|
15
|
+
import { parseModelUri } from "../llm/cache";
|
|
16
|
+
import { getPreset } from "../llm/registry";
|
|
17
|
+
import { canonicalOperationalPath } from "./config-write-lock";
|
|
18
|
+
import { normalizePersistedContextText } from "./context-identity";
|
|
19
|
+
import { hasLikelySecretPath } from "./path-rules";
|
|
20
|
+
import {
|
|
21
|
+
canonicalProjectProfileJson,
|
|
22
|
+
compareCodeUnits,
|
|
23
|
+
fingerprintProjectProfileState,
|
|
24
|
+
normalizeLogicalPath,
|
|
25
|
+
projectProfileIncludePattern,
|
|
26
|
+
sha256,
|
|
27
|
+
sortedUnique,
|
|
28
|
+
} from "./project-profile-canonical";
|
|
29
|
+
import { parseProjectProfile } from "./project-profile-parser";
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
canonicalProjectProfileJson,
|
|
33
|
+
fingerprintProjectProfileState,
|
|
34
|
+
projectProfileIncludePattern,
|
|
35
|
+
} from "./project-profile-canonical";
|
|
36
|
+
export type ProjectProfileDiagnosticCode =
|
|
37
|
+
| "COLLECTION_ROOT_NOT_DIRECTORY"
|
|
38
|
+
| "CONTEXT_FILE_INVALID"
|
|
39
|
+
| "CONTEXT_FILE_NOT_REGULAR"
|
|
40
|
+
| "CONTEXT_FILE_TOO_LARGE"
|
|
41
|
+
| "CONTEXT_FILE_UNREADABLE"
|
|
42
|
+
| "CONTEXT_TEXT_EMPTY"
|
|
43
|
+
| "INVALID_PROFILE"
|
|
44
|
+
| "MIGRATION_REQUIRED"
|
|
45
|
+
| "MODEL_CACHE_CHECK_FAILED"
|
|
46
|
+
| "MODEL_PATH_OVERLAP"
|
|
47
|
+
| "MODEL_PRESET_NOT_FOUND"
|
|
48
|
+
| "MODEL_PRESET_UNAVAILABLE_OFFLINE"
|
|
49
|
+
| "PATH_NOT_FOUND"
|
|
50
|
+
| "SYMLINK_ESCAPE"
|
|
51
|
+
| "UNSAFE_PATH"
|
|
52
|
+
| "UNSUPPORTED_SCHEMA_MAJOR"
|
|
53
|
+
| "UNSUPPORTED_SCHEMA_MINOR";
|
|
54
|
+
|
|
55
|
+
export interface ProjectProfileDiagnostic {
|
|
56
|
+
code: ProjectProfileDiagnosticCode;
|
|
57
|
+
severity: "error" | "warning";
|
|
58
|
+
path: string;
|
|
59
|
+
message: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ProjectProfileContextState {
|
|
63
|
+
scopeType: "collection";
|
|
64
|
+
scopeKey: string;
|
|
65
|
+
text: string;
|
|
66
|
+
source:
|
|
67
|
+
| { kind: "inline"; sha256: string }
|
|
68
|
+
| { kind: "file"; path: string; sha256: string };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ProjectProfileDesiredState {
|
|
72
|
+
schemaVersion: typeof PROJECT_PROFILE_SCHEMA_VERSION;
|
|
73
|
+
collection: {
|
|
74
|
+
name: string;
|
|
75
|
+
root: string;
|
|
76
|
+
include: string[];
|
|
77
|
+
exclude: string[];
|
|
78
|
+
languageHint?: string;
|
|
79
|
+
modelPreset?: string;
|
|
80
|
+
};
|
|
81
|
+
contexts: ProjectProfileContextState[];
|
|
82
|
+
contentTypes: Array<{
|
|
83
|
+
id: string;
|
|
84
|
+
prefixes: string[];
|
|
85
|
+
preset: string;
|
|
86
|
+
graphHints?: string[];
|
|
87
|
+
searchBoost?: number;
|
|
88
|
+
temporal?: boolean;
|
|
89
|
+
}>;
|
|
90
|
+
affinityDefaults: {
|
|
91
|
+
enabled: boolean;
|
|
92
|
+
contribution: number;
|
|
93
|
+
};
|
|
94
|
+
recommendedCapabilities: string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ResolvedProjectProfilePaths {
|
|
98
|
+
profileRoot: string;
|
|
99
|
+
collectionRoot: string;
|
|
100
|
+
contextFiles: Array<{
|
|
101
|
+
logicalPath: string;
|
|
102
|
+
absolutePath: string;
|
|
103
|
+
}>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface CompiledProjectProfile {
|
|
107
|
+
profile: ProjectProfile;
|
|
108
|
+
desiredState: ProjectProfileDesiredState;
|
|
109
|
+
canonicalJson: string;
|
|
110
|
+
fingerprint: string;
|
|
111
|
+
resolvedPaths: ResolvedProjectProfilePaths;
|
|
112
|
+
diagnostics: ProjectProfileDiagnostic[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type CompileProjectProfileResult =
|
|
116
|
+
| { ok: true; value: CompiledProjectProfile }
|
|
117
|
+
| { ok: false; diagnostics: ProjectProfileDiagnostic[] };
|
|
118
|
+
|
|
119
|
+
export interface ProjectProfileCompilerOptions {
|
|
120
|
+
profileRoot: string;
|
|
121
|
+
config?: Config;
|
|
122
|
+
isModelAvailableOffline?: (
|
|
123
|
+
modelUri: string,
|
|
124
|
+
modelType: "embed" | "rerank" | "expand" | "gen"
|
|
125
|
+
) => Promise<boolean>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES = 65_536;
|
|
129
|
+
|
|
130
|
+
const isContained = (parent: string, candidate: string): boolean => {
|
|
131
|
+
const pathFromParent = relative(parent, candidate);
|
|
132
|
+
return (
|
|
133
|
+
pathFromParent === "" ||
|
|
134
|
+
(pathFromParent !== ".." &&
|
|
135
|
+
!pathFromParent.startsWith(`..${sep}`) &&
|
|
136
|
+
!isAbsolute(pathFromParent))
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const resolveContainedPath = async (
|
|
141
|
+
profileRoot: string,
|
|
142
|
+
logicalPath: string,
|
|
143
|
+
diagnosticPath: string
|
|
144
|
+
): Promise<
|
|
145
|
+
| {
|
|
146
|
+
ok: true;
|
|
147
|
+
absolutePath: string;
|
|
148
|
+
metadata: Awaited<ReturnType<typeof stat>>;
|
|
149
|
+
}
|
|
150
|
+
| { ok: false; diagnostic: ProjectProfileDiagnostic }
|
|
151
|
+
> => {
|
|
152
|
+
const candidate = resolve(profileRoot, normalizeLogicalPath(logicalPath));
|
|
153
|
+
let absolutePath: string;
|
|
154
|
+
let metadata: Awaited<ReturnType<typeof stat>>;
|
|
155
|
+
try {
|
|
156
|
+
absolutePath = await realpath(candidate);
|
|
157
|
+
metadata = await stat(absolutePath);
|
|
158
|
+
} catch {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
diagnostic: {
|
|
162
|
+
code: "PATH_NOT_FOUND",
|
|
163
|
+
severity: "error",
|
|
164
|
+
path: diagnosticPath,
|
|
165
|
+
message: "Referenced project path does not exist.",
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (!isContained(profileRoot, absolutePath)) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
diagnostic: {
|
|
173
|
+
code: "SYMLINK_ESCAPE",
|
|
174
|
+
severity: "error",
|
|
175
|
+
path: diagnosticPath,
|
|
176
|
+
message:
|
|
177
|
+
"Referenced project path resolves outside the trusted profile root.",
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { ok: true, absolutePath, metadata };
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const modelUris = (
|
|
185
|
+
preset: ModelPreset
|
|
186
|
+
): Array<["embed" | "rerank" | "expand" | "gen", string]> => [
|
|
187
|
+
["embed", preset.embed],
|
|
188
|
+
["rerank", preset.rerank],
|
|
189
|
+
["expand", preset.expand ?? preset.gen],
|
|
190
|
+
["gen", preset.gen],
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
const diagnoseModelPreset = async (
|
|
194
|
+
profile: ProjectProfile,
|
|
195
|
+
options: ProjectProfileCompilerOptions,
|
|
196
|
+
profileRoot: string
|
|
197
|
+
): Promise<ProjectProfileDiagnostic[]> => {
|
|
198
|
+
const presetId = profile.collection.modelPreset;
|
|
199
|
+
if (!presetId) return [];
|
|
200
|
+
const preset = getPreset(options.config ?? createDefaultConfig(), presetId);
|
|
201
|
+
if (!preset) {
|
|
202
|
+
return [
|
|
203
|
+
{
|
|
204
|
+
code: "MODEL_PRESET_NOT_FOUND",
|
|
205
|
+
severity: "error",
|
|
206
|
+
path: "collection.modelPreset",
|
|
207
|
+
message: `Model preset alias "${presetId}" is not configured.`,
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
for (const [, uri] of modelUris(preset)) {
|
|
212
|
+
const parsed = parseModelUri(uri);
|
|
213
|
+
if (!parsed.ok || parsed.value.scheme !== "file") continue;
|
|
214
|
+
const modelPath = await canonicalOperationalPath(parsed.value.file);
|
|
215
|
+
if (isContained(profileRoot, modelPath)) {
|
|
216
|
+
return [
|
|
217
|
+
{
|
|
218
|
+
code: "MODEL_PATH_OVERLAP",
|
|
219
|
+
severity: "error",
|
|
220
|
+
path: "collection.modelPreset",
|
|
221
|
+
message:
|
|
222
|
+
"The selected model preset resolves runtime model state inside the project profile root.",
|
|
223
|
+
},
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (!options.isModelAvailableOffline) return [];
|
|
228
|
+
|
|
229
|
+
const unavailable: string[] = [];
|
|
230
|
+
try {
|
|
231
|
+
for (const [modelType, uri] of modelUris(preset)) {
|
|
232
|
+
if (!(await options.isModelAvailableOffline(uri, modelType))) {
|
|
233
|
+
unavailable.push(modelType);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
return [
|
|
238
|
+
{
|
|
239
|
+
code: "MODEL_CACHE_CHECK_FAILED",
|
|
240
|
+
severity: "error",
|
|
241
|
+
path: "collection.modelPreset",
|
|
242
|
+
message: `Offline cache availability could not be verified for model preset alias "${presetId}".`,
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
if (unavailable.length === 0) return [];
|
|
247
|
+
return [
|
|
248
|
+
{
|
|
249
|
+
code: "MODEL_PRESET_UNAVAILABLE_OFFLINE",
|
|
250
|
+
severity: "error",
|
|
251
|
+
path: "collection.modelPreset",
|
|
252
|
+
message: `Model preset alias "${presetId}" is unavailable offline for: ${unavailable.join(", ")}.`,
|
|
253
|
+
},
|
|
254
|
+
];
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export async function compileProjectProfileYaml(
|
|
258
|
+
yaml: string,
|
|
259
|
+
options: ProjectProfileCompilerOptions
|
|
260
|
+
): Promise<CompileProjectProfileResult> {
|
|
261
|
+
const parsed = parseProjectProfile(yaml);
|
|
262
|
+
if ("ok" in parsed) return parsed;
|
|
263
|
+
const profile = parsed;
|
|
264
|
+
const diagnostics: ProjectProfileDiagnostic[] = [];
|
|
265
|
+
|
|
266
|
+
let profileRoot: string;
|
|
267
|
+
try {
|
|
268
|
+
profileRoot = await realpath(options.profileRoot);
|
|
269
|
+
} catch {
|
|
270
|
+
return {
|
|
271
|
+
ok: false,
|
|
272
|
+
diagnostics: [
|
|
273
|
+
{
|
|
274
|
+
code: "PATH_NOT_FOUND",
|
|
275
|
+
severity: "error",
|
|
276
|
+
path: "profileRoot",
|
|
277
|
+
message: "Trusted project profile root does not exist.",
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const collectionRoot = await resolveContainedPath(
|
|
284
|
+
profileRoot,
|
|
285
|
+
profile.collection.root,
|
|
286
|
+
"collection.root"
|
|
287
|
+
);
|
|
288
|
+
if (!collectionRoot.ok) diagnostics.push(collectionRoot.diagnostic);
|
|
289
|
+
else if (!collectionRoot.metadata.isDirectory()) {
|
|
290
|
+
diagnostics.push({
|
|
291
|
+
code: "COLLECTION_ROOT_NOT_DIRECTORY",
|
|
292
|
+
severity: "error",
|
|
293
|
+
path: "collection.root",
|
|
294
|
+
message: "The collection root must resolve to a directory.",
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const contexts: ProjectProfileContextState[] = [];
|
|
299
|
+
const contextFiles: ResolvedProjectProfilePaths["contextFiles"] = [];
|
|
300
|
+
for (const [index, context] of profile.contexts.entries()) {
|
|
301
|
+
if ("text" in context) {
|
|
302
|
+
const normalizedText = normalizePersistedContextText(context.text);
|
|
303
|
+
if (!normalizedText) {
|
|
304
|
+
diagnostics.push({
|
|
305
|
+
code: "CONTEXT_TEXT_EMPTY",
|
|
306
|
+
severity: "error",
|
|
307
|
+
path: `contexts[${index}].text`,
|
|
308
|
+
message: "Context text must not be empty after normalization.",
|
|
309
|
+
});
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
contexts.push({
|
|
313
|
+
scopeType: "collection",
|
|
314
|
+
scopeKey: `${profile.collection.name}:`,
|
|
315
|
+
text: normalizedText,
|
|
316
|
+
source: { kind: "inline", sha256: sha256(context.text) },
|
|
317
|
+
});
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const resolved = await resolveContainedPath(
|
|
322
|
+
profileRoot,
|
|
323
|
+
context.file,
|
|
324
|
+
`contexts[${index}].file`
|
|
325
|
+
);
|
|
326
|
+
if (!resolved.ok) {
|
|
327
|
+
diagnostics.push(resolved.diagnostic);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const resolvedLogicalPath = normalizeLogicalPath(
|
|
331
|
+
relative(profileRoot, resolved.absolutePath)
|
|
332
|
+
);
|
|
333
|
+
if (hasLikelySecretPath(resolvedLogicalPath)) {
|
|
334
|
+
diagnostics.push({
|
|
335
|
+
code: "UNSAFE_PATH",
|
|
336
|
+
severity: "error",
|
|
337
|
+
path: `contexts[${index}].file`,
|
|
338
|
+
message:
|
|
339
|
+
"Context input resolves to a likely credential or secret file.",
|
|
340
|
+
});
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (!resolved.metadata.isFile()) {
|
|
344
|
+
diagnostics.push({
|
|
345
|
+
code: "CONTEXT_FILE_NOT_REGULAR",
|
|
346
|
+
severity: "error",
|
|
347
|
+
path: `contexts[${index}].file`,
|
|
348
|
+
message: "Context input must resolve to a regular file.",
|
|
349
|
+
});
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (resolved.metadata.size > PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES) {
|
|
353
|
+
diagnostics.push({
|
|
354
|
+
code: "CONTEXT_FILE_TOO_LARGE",
|
|
355
|
+
severity: "error",
|
|
356
|
+
path: `contexts[${index}].file`,
|
|
357
|
+
message: `Context file exceeds ${PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES} bytes.`,
|
|
358
|
+
});
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
let bytes: Uint8Array;
|
|
362
|
+
try {
|
|
363
|
+
bytes = new Uint8Array(
|
|
364
|
+
await Bun.file(resolved.absolutePath)
|
|
365
|
+
.slice(0, PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES + 1)
|
|
366
|
+
.arrayBuffer()
|
|
367
|
+
);
|
|
368
|
+
} catch {
|
|
369
|
+
diagnostics.push({
|
|
370
|
+
code: "CONTEXT_FILE_UNREADABLE",
|
|
371
|
+
severity: "error",
|
|
372
|
+
path: `contexts[${index}].file`,
|
|
373
|
+
message: "Context file could not be read.",
|
|
374
|
+
});
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (bytes.byteLength > PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES) {
|
|
378
|
+
diagnostics.push({
|
|
379
|
+
code: "CONTEXT_FILE_TOO_LARGE",
|
|
380
|
+
severity: "error",
|
|
381
|
+
path: `contexts[${index}].file`,
|
|
382
|
+
message: `Context file exceeds ${PROJECT_PROFILE_CONTEXT_FILE_MAX_BYTES} bytes.`,
|
|
383
|
+
});
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
let text: string;
|
|
387
|
+
try {
|
|
388
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
389
|
+
} catch {
|
|
390
|
+
diagnostics.push({
|
|
391
|
+
code: "CONTEXT_FILE_INVALID",
|
|
392
|
+
severity: "error",
|
|
393
|
+
path: `contexts[${index}].file`,
|
|
394
|
+
message: "Context file is not valid UTF-8.",
|
|
395
|
+
});
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const logicalPath = normalizeLogicalPath(context.file);
|
|
399
|
+
const normalizedText = normalizePersistedContextText(text);
|
|
400
|
+
if (!normalizedText) {
|
|
401
|
+
diagnostics.push({
|
|
402
|
+
code: "CONTEXT_TEXT_EMPTY",
|
|
403
|
+
severity: "error",
|
|
404
|
+
path: `contexts[${index}].file`,
|
|
405
|
+
message: "Context file must not be empty after normalization.",
|
|
406
|
+
});
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
contexts.push({
|
|
410
|
+
scopeType: "collection",
|
|
411
|
+
scopeKey: `${profile.collection.name}:`,
|
|
412
|
+
text: normalizedText,
|
|
413
|
+
source: { kind: "file", path: logicalPath, sha256: sha256(bytes) },
|
|
414
|
+
});
|
|
415
|
+
contextFiles.push({
|
|
416
|
+
logicalPath,
|
|
417
|
+
absolutePath: resolved.absolutePath,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
diagnostics.push(
|
|
422
|
+
...(await diagnoseModelPreset(profile, options, profileRoot))
|
|
423
|
+
);
|
|
424
|
+
if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
|
425
|
+
return { ok: false, diagnostics };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const normalizedContentTypes = normalizeContentTypes(
|
|
429
|
+
Object.entries(profile.contentTypes).map(([id, rule]) => ({ id, ...rule }))
|
|
430
|
+
).rules.map((rule) => ({
|
|
431
|
+
id: rule.id,
|
|
432
|
+
prefixes: sortedUnique(rule.prefixes.map(normalizeLogicalPath)),
|
|
433
|
+
preset: rule.preset,
|
|
434
|
+
...(rule.graphHints ? { graphHints: sortedUnique(rule.graphHints) } : {}),
|
|
435
|
+
...(rule.searchBoost === undefined
|
|
436
|
+
? {}
|
|
437
|
+
: { searchBoost: rule.searchBoost }),
|
|
438
|
+
...(rule.temporal === undefined ? {} : { temporal: rule.temporal }),
|
|
439
|
+
}));
|
|
440
|
+
|
|
441
|
+
const desiredState: ProjectProfileDesiredState = {
|
|
442
|
+
schemaVersion: PROJECT_PROFILE_SCHEMA_VERSION,
|
|
443
|
+
collection: {
|
|
444
|
+
name: profile.collection.name,
|
|
445
|
+
root: normalizeLogicalPath(profile.collection.root),
|
|
446
|
+
include: sortedUnique(
|
|
447
|
+
profile.collection.include.map(normalizeLogicalPath)
|
|
448
|
+
),
|
|
449
|
+
exclude: sortedUnique([
|
|
450
|
+
...profile.collection.exclude.map(normalizeLogicalPath),
|
|
451
|
+
...PROJECT_PROFILE_FORCED_EXCLUDES,
|
|
452
|
+
]),
|
|
453
|
+
...(profile.collection.languageHint
|
|
454
|
+
? { languageHint: profile.collection.languageHint }
|
|
455
|
+
: {}),
|
|
456
|
+
...(profile.collection.modelPreset
|
|
457
|
+
? { modelPreset: profile.collection.modelPreset }
|
|
458
|
+
: {}),
|
|
459
|
+
},
|
|
460
|
+
contexts: contexts.sort((left, right) =>
|
|
461
|
+
compareCodeUnits(
|
|
462
|
+
canonicalProjectProfileJson(left),
|
|
463
|
+
canonicalProjectProfileJson(right)
|
|
464
|
+
)
|
|
465
|
+
),
|
|
466
|
+
contentTypes: normalizedContentTypes,
|
|
467
|
+
affinityDefaults: profile.affinityDefaults,
|
|
468
|
+
recommendedCapabilities: sortedUnique(profile.recommendedCapabilities),
|
|
469
|
+
};
|
|
470
|
+
const canonicalJson = canonicalProjectProfileJson(desiredState);
|
|
471
|
+
return {
|
|
472
|
+
ok: true,
|
|
473
|
+
value: {
|
|
474
|
+
profile,
|
|
475
|
+
desiredState,
|
|
476
|
+
canonicalJson,
|
|
477
|
+
fingerprint: fingerprintProjectProfileState(desiredState),
|
|
478
|
+
resolvedPaths: {
|
|
479
|
+
profileRoot,
|
|
480
|
+
collectionRoot: collectionRoot.ok
|
|
481
|
+
? collectionRoot.absolutePath
|
|
482
|
+
: profileRoot,
|
|
483
|
+
contextFiles: contextFiles.sort((left, right) =>
|
|
484
|
+
compareCodeUnits(left.logicalPath, right.logicalPath)
|
|
485
|
+
),
|
|
486
|
+
},
|
|
487
|
+
diagnostics,
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
package/src/ingestion/walker.ts
CHANGED
|
@@ -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
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 (
|
|
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.
|