machine-bridge-mcp 0.8.2 → 0.10.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.
@@ -0,0 +1,862 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { lstat, open, opendir, realpath, stat } from "node:fs/promises";
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+
6
+ const CONFIG_RELATIVE_PATH = join(".machine-bridge", "agent.json");
7
+ const GLOBAL_CONFIG_RELATIVE_PATH = join(".config", "machine-bridge-mcp", "agent.json");
8
+ const DEFAULT_INSTRUCTION_FILES = Object.freeze(["AGENTS.override.md", "AGENTS.md"]);
9
+ const DEFAULT_INSTRUCTION_MAX_BYTES = 32 * 1024;
10
+ const MAX_CONTEXT_SKILL_SUMMARY_CHARS = 8_000;
11
+ const MAX_CONFIG_BYTES = 256 * 1024;
12
+ const MAX_INSTRUCTION_FILE_BYTES = 512 * 1024;
13
+ const MAX_TOTAL_INSTRUCTION_BYTES = 2 * 1024 * 1024;
14
+ const MAX_INSTRUCTION_FILES = 64;
15
+ const MAX_SKILL_ENTRY_BYTES = 512 * 1024;
16
+ const MAX_SKILL_ROOTS = 32;
17
+ const MAX_SKILL_RESULTS = 500;
18
+ const MAX_SKILL_SCAN_ENTRIES = 20_000;
19
+ const MAX_SKILL_SCAN_DEPTH = 8;
20
+ const MAX_SKILL_FILES = 500;
21
+ const MAX_COMMANDS = 128;
22
+ const MAX_COMMAND_ARGV = 128;
23
+ const MAX_COMMAND_ARGUMENT_BYTES = 256 * 1024;
24
+ const COMMAND_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
25
+ const TOKEN_STOP_WORDS = new Set(["the", "and", "for", "with", "from", "this", "that", "use", "using", "into", "of", "to", "a", "an", "in", "on", "is", "are", "or", "及", "和", "的", "了", "在", "用", "使用", "进行", "根据"]);
26
+ const CONFIG_KEYS = new Set(["version", "model_instructions_file", "instruction_files", "instruction_max_bytes", "skill_roots", "commands"]);
27
+ const COMMAND_KEYS = new Set(["description", "argv", "cwd", "timeout_seconds", "allow_extra_args"]);
28
+
29
+ export class AgentContextManager {
30
+ constructor({ workspace, policy, displayPath, resolveExistingPath, throwIfCancelled = () => {}, home = process.env.HOME || process.env.USERPROFILE || "", codexHome = process.env.CODEX_HOME || "" }) {
31
+ this.workspace = resolve(workspace);
32
+ this.policy = policy || {};
33
+ this.displayPath = displayPath;
34
+ this.resolveExistingPath = resolveExistingPath;
35
+ this.throwIfCancelled = throwIfCancelled;
36
+ this.home = home ? resolve(home) : "";
37
+ this.codexHome = codexHome ? resolve(codexHome) : this.home ? resolve(this.home, ".codex") : "";
38
+ }
39
+
40
+ async agentContext(args = {}, context = {}) {
41
+ const state = await this.discoverState(args.path || ".", context);
42
+ const includeContent = args.include_instruction_content !== false;
43
+ const skillLimit = clampInt(args.max_skills, 100, 1, MAX_SKILL_RESULTS);
44
+ const discoveredSkills = await this.discoverSkills(state, { maxResults: MAX_SKILL_RESULTS }, context);
45
+ const contextSkills = contextSkillSummaries(discoveredSkills.skills, this.displayPath, skillLimit, MAX_CONTEXT_SKILL_SUMMARY_CHARS);
46
+ const result = {
47
+ target: this.displayPath(state.target),
48
+ scope_root: this.displayPath(state.scopeRoot),
49
+ precedence: "global guidance first, then scope root to target directory; each directory contributes the first non-empty instruction_files candidate; later directories have higher precedence",
50
+ config_files: state.configFiles.map((file) => this.displayPath(file)),
51
+ model_instructions_file: state.modelInstructions ? {
52
+ path: this.displayPath(state.modelInstructions.path),
53
+ bytes: state.modelInstructions.bytes,
54
+ sha256: state.modelInstructions.sha256,
55
+ ...(includeContent ? { content: state.modelInstructions.content } : {}),
56
+ } : null,
57
+ instruction_files: state.instructions.map((item) => ({
58
+ scope: item.scope,
59
+ path: this.displayPath(item.path),
60
+ bytes: item.bytes,
61
+ sha256: item.sha256,
62
+ precedence: item.precedence,
63
+ ...(includeContent ? { content: item.content } : {}),
64
+ })),
65
+ skills: contextSkills.skills,
66
+ skills_truncated: discoveredSkills.truncated || contextSkills.truncated,
67
+ skill_warnings: publicSkillWarnings(discoveredSkills.warnings, this.displayPath),
68
+ instructions_truncated: state.instructionsTruncated,
69
+ commands: publicCommands(state.commands, this.displayPath),
70
+ guidance: [
71
+ "Treat instruction_files as authoritative workspace guidance in the returned precedence order.",
72
+ "Load a relevant skill with load_local_skill before following its workflow; SKILL.md is an instruction bundle, not executable code by itself.",
73
+ "Prefer run_local_command for registered repeatable commands. Use run_process or exec_command only when no registered command fits and policy permits it.",
74
+ ],
75
+ };
76
+ if (includeContent) result.effective_instructions = renderEffectiveInstructions([...(state.modelInstructions ? [state.modelInstructions] : []), ...state.instructions], this.displayPath);
77
+ return result;
78
+ }
79
+
80
+
81
+ async sessionBootstrap(args = {}, context = {}) {
82
+ const state = await this.discoverState(args.path || ".", context);
83
+ const fingerprint = capabilityFingerprint(state, []);
84
+ return {
85
+ target: this.displayPath(state.target),
86
+ instructions: renderEffectiveInstructions([...(state.modelInstructions ? [state.modelInstructions] : []), ...state.instructions], this.displayPath),
87
+ model_instructions_file: state.modelInstructions ? this.displayPath(state.modelInstructions.path) : null,
88
+ capability_refresh: {
89
+ strategy: "resolve_task_capabilities-rescans-on-every-call",
90
+ instruction_and_command_fingerprint: fingerprint,
91
+ skills_scanned: false,
92
+ generated_at: new Date().toISOString(),
93
+ },
94
+ guidance: [
95
+ "Call resolve_task_capabilities with the current user task before substantive local work or at the start of a reused-host conversation.",
96
+ "The resolver returns the effective instructions again and rescans skill and command metadata on every call; the runtime supplements application and browser capability metadata.",
97
+ ],
98
+ };
99
+ }
100
+
101
+ async resolveTaskCapabilities(args = {}, context = {}) {
102
+ const task = requiredString(args.task, "task");
103
+ if (task.length > 20_000) throw new Error("task exceeds 20000 characters");
104
+ const state = await this.discoverState(args.path || ".", context);
105
+ const discovered = await this.discoverSkills(state, { maxResults: MAX_SKILL_RESULTS }, context);
106
+ const maxSkills = clampInt(args.max_skills, 10, 1, 50);
107
+ const skillMatches = discovered.skills
108
+ .map((skill) => ({ skill, score: relevanceScore(task, `${skill.name} ${skill.description}`) }))
109
+ .filter((item) => item.score > 0)
110
+ .sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name))
111
+ .slice(0, maxSkills);
112
+ const commandMatches = [...state.commands.values()]
113
+ .map((command) => ({ command, score: relevanceScore(task, `${command.name} ${command.description} ${command.argv.join(" ")}`) }))
114
+ .filter((item) => item.score > 0)
115
+ .sort((a, b) => b.score - a.score || a.command.name.localeCompare(b.command.name))
116
+ .slice(0, 20);
117
+ const selected = skillMatches[0]?.score >= 3 ? skillMatches[0].skill : null;
118
+ let selectedSkill = null;
119
+ if (selected && args.include_selected_skill !== false) {
120
+ const content = await readRegularUtf8(selected.entrypoint, MAX_SKILL_ENTRY_BYTES, "skill entrypoint");
121
+ selectedSkill = { ...publicSkill(selected, this.displayPath), instructions: content.text };
122
+ }
123
+ const recommendedTools = recommendTools(task, commandMatches.length > 0, skillMatches.length > 0);
124
+ const refresh = capabilityFingerprint(state, discovered.skills);
125
+ return {
126
+ task,
127
+ target: this.displayPath(state.target),
128
+ effective_instructions: renderEffectiveInstructions([...(state.modelInstructions ? [state.modelInstructions] : []), ...state.instructions], this.displayPath),
129
+ model_instructions_file: state.modelInstructions ? this.displayPath(state.modelInstructions.path) : null,
130
+ instruction_files: state.instructions.map((item) => ({ path: this.displayPath(item.path), scope: item.scope, bytes: item.bytes, sha256: item.sha256, precedence: item.precedence })),
131
+ instructions_truncated: state.instructionsTruncated,
132
+ refresh: { strategy: "rescan-on-every-call", fingerprint: refresh, generated_at: new Date().toISOString() },
133
+ selected_skill: selectedSkill,
134
+ skill_matches: skillMatches.map(({ skill, score }) => ({ ...publicSkill(skill, this.displayPath), score })),
135
+ command_matches: commandMatches.map(({ command, score }) => ({ ...publicCommands(new Map([[command.name, command]]), this.displayPath)[0], score })),
136
+ recommended_tools: recommendedTools,
137
+ host_semantics: "Machine Bridge can discover, rank, and load capabilities automatically. The MCP host remains responsible for deciding whether to call the recommended tools.",
138
+ warnings: publicSkillWarnings(discovered.warnings, this.displayPath),
139
+ truncated: discovered.truncated,
140
+ };
141
+ }
142
+
143
+ async collectModelInstructions(state, context) {
144
+ this.throwIfCancelled(context);
145
+ const path = state.modelInstructionsFile;
146
+ const info = await lstat(path).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
147
+ if (!info) throw new Error(`model_instructions_file does not exist: ${path}`);
148
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error(`model_instructions_file must be a regular non-symbolic-link file: ${path}`);
149
+ const content = await readRegularUtf8(path, MAX_INSTRUCTION_FILE_BYTES, "model instructions file");
150
+ if (!content.text.trim()) throw new Error(`model_instructions_file is empty: ${path}`);
151
+ state.modelInstructions = { scope: "model", path, bytes: content.bytes, sha256: sha256(content.text), content: content.text, precedence: 0 };
152
+ }
153
+
154
+ async listLocalSkills(args = {}, context = {}) {
155
+ const state = await this.discoverState(args.path || ".", context);
156
+ const result = await this.discoverSkills(state, {
157
+ query: typeof args.query === "string" ? args.query : "",
158
+ maxResults: clampInt(args.max_results, 100, 1, MAX_SKILL_RESULTS),
159
+ }, context);
160
+ return {
161
+ target: this.displayPath(state.target),
162
+ scope_root: this.displayPath(state.scopeRoot),
163
+ skill_roots: state.skillRoots.map((root) => this.displayPath(root)),
164
+ skills: result.skills.map((skill) => publicSkill(skill, this.displayPath)),
165
+ warnings: publicSkillWarnings(result.warnings, this.displayPath),
166
+ truncated: result.truncated,
167
+ };
168
+ }
169
+
170
+ async loadLocalSkill(args = {}, context = {}) {
171
+ const requested = requiredString(args.skill, "skill");
172
+ const state = await this.discoverState(args.path || ".", context);
173
+ const result = await this.discoverSkills(state, { maxResults: MAX_SKILL_RESULTS }, context);
174
+ const matches = result.skills.filter((skill) => skill.id === requested || skill.name === requested || this.displayPath(skill.entrypoint) === requested);
175
+ if (!matches.length) throw new Error(`local skill not found: ${requested}`);
176
+ if (matches.length > 1) throw new Error(`local skill name is ambiguous; use its id: ${requested}`);
177
+ const skill = matches[0];
178
+ const content = await readRegularUtf8(skill.entrypoint, MAX_SKILL_ENTRY_BYTES, "skill entrypoint");
179
+ const inventory = await listSkillFiles(skill.directory, clampInt(args.max_files, 200, 1, MAX_SKILL_FILES), context, this.throwIfCancelled);
180
+ return {
181
+ skill: publicSkill(skill, this.displayPath),
182
+ instructions: content.text,
183
+ files: inventory.files,
184
+ files_truncated: inventory.truncated,
185
+ execution_semantics: "Loading a skill returns its instructions and file inventory. It does not execute scripts or commands implicitly.",
186
+ };
187
+ }
188
+
189
+ async listLocalCommands(args = {}, context = {}) {
190
+ const state = await this.discoverState(args.path || ".", context);
191
+ return {
192
+ target: this.displayPath(state.target),
193
+ scope_root: this.displayPath(state.scopeRoot),
194
+ commands: publicCommands(state.commands, this.displayPath),
195
+ };
196
+ }
197
+
198
+ async resolveLocalCommand(args = {}, context = {}) {
199
+ const name = requiredString(args.name, "name");
200
+ const state = await this.discoverState(args.path || ".", context);
201
+ const command = state.commands.get(name);
202
+ if (!command) throw new Error(`registered local command not found: ${name}`);
203
+ const extraArgs = args.args === undefined ? [] : validateStringArray(args.args, "args", 64, MAX_COMMAND_ARGUMENT_BYTES);
204
+ if (extraArgs.length && !command.allowExtraArgs) throw new Error(`registered local command does not accept extra args: ${name}`);
205
+ return {
206
+ name,
207
+ description: command.description,
208
+ argv: [...command.argv, ...extraArgs],
209
+ cwd: command.cwd,
210
+ timeoutSeconds: command.timeoutSeconds,
211
+ source: command.source,
212
+ };
213
+ }
214
+
215
+ async discoverState(inputPath, context = {}) {
216
+ this.throwIfCancelled(context);
217
+ this.workspace = await realpath(this.workspace);
218
+ const target = await realpath(await this.resolveExistingPath(inputPath));
219
+ const targetInfo = await stat(target);
220
+ const targetDir = targetInfo.isDirectory() ? target : dirname(target);
221
+ const scopeRoot = await findScopeRoot({
222
+ targetDir,
223
+ workspace: this.workspace,
224
+ unrestricted: this.policy.unrestrictedPaths === true,
225
+ });
226
+ const directories = directoriesBetween(scopeRoot, targetDir);
227
+ const state = {
228
+ target,
229
+ targetDir,
230
+ scopeRoot,
231
+ instructionFiles: [...DEFAULT_INSTRUCTION_FILES],
232
+ instructionMaxBytes: DEFAULT_INSTRUCTION_MAX_BYTES,
233
+ skillRoots: defaultSkillRoots(directories, this.home, this.policy.unrestrictedPaths === true),
234
+ commands: new Map(),
235
+ modelInstructionsFile: "",
236
+ modelInstructions: null,
237
+ configFiles: [],
238
+ instructions: [],
239
+ instructionBytes: 0,
240
+ instructionsTruncated: false,
241
+ };
242
+
243
+ if (this.home) {
244
+ const globalConfig = join(this.home, GLOBAL_CONFIG_RELATIVE_PATH);
245
+ const config = await readOptionalConfig(globalConfig, this.home, false);
246
+ if (config) {
247
+ if (this.policy.unrestrictedPaths === true) this.applyConfig(state, config, globalConfig, this.home, { global: true });
248
+ else {
249
+ state.configFiles.push(globalConfig);
250
+ if (config.modelInstructionsFile) state.modelInstructionsFile = resolveConfiguredPath(config.modelInstructionsFile, this.home, this.home, this.workspace, true);
251
+ }
252
+ }
253
+ if (state.modelInstructionsFile) await this.collectModelInstructions(state, context);
254
+ if (this.policy.unrestrictedPaths === true && this.codexHome) await this.collectDirectoryInstruction(state, this.codexHome, context, "global");
255
+ }
256
+
257
+ for (const directory of directories) {
258
+ this.throwIfCancelled(context);
259
+ const configPath = join(directory, CONFIG_RELATIVE_PATH);
260
+ const config = await readOptionalConfig(configPath, directory, true);
261
+ if (config) this.applyConfig(state, config, configPath, directory, { global: false });
262
+ await this.collectDirectoryInstruction(state, directory, context, "project");
263
+ }
264
+ return state;
265
+ }
266
+
267
+ applyConfig(state, config, configPath, baseDir, { global = false } = {}) {
268
+ state.configFiles.push(configPath);
269
+ if (config.modelInstructionsFile) {
270
+ if (!global) throw new Error(`model_instructions_file is only allowed in the global agent config: ${configPath}`);
271
+ state.modelInstructionsFile = resolveConfiguredPath(config.modelInstructionsFile, baseDir, this.home, this.workspace, true);
272
+ }
273
+ if (config.instructionFiles) state.instructionFiles = [...config.instructionFiles];
274
+ if (config.instructionMaxBytes !== null) {
275
+ state.instructionMaxBytes = config.instructionMaxBytes;
276
+ if (state.instructionBytes >= state.instructionMaxBytes) state.instructionsTruncated = true;
277
+ }
278
+ if (config.skillRoots) {
279
+ state.skillRoots = config.skillRoots.map((value) => resolveConfiguredPath(value, baseDir, this.home, this.workspace, this.policy.unrestrictedPaths === true));
280
+ }
281
+ for (const [name, definition] of config.commands) {
282
+ if (definition === null) {
283
+ state.commands.delete(name);
284
+ continue;
285
+ }
286
+ const cwd = resolveConfiguredPath(definition.cwd, baseDir, this.home, this.workspace, this.policy.unrestrictedPaths === true);
287
+ state.commands.set(name, {
288
+ name,
289
+ description: definition.description,
290
+ argv: definition.argv,
291
+ cwd,
292
+ timeoutSeconds: definition.timeoutSeconds,
293
+ allowExtraArgs: definition.allowExtraArgs,
294
+ source: configPath,
295
+ });
296
+ }
297
+ if (state.commands.size > MAX_COMMANDS) throw new Error(`agent config defines more than ${MAX_COMMANDS} commands`);
298
+ }
299
+
300
+ async collectDirectoryInstruction(state, directory, context, scope) {
301
+ if (state.instructionsTruncated) return;
302
+ const directoryInfo = await lstat(directory).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
303
+ if (!directoryInfo) return;
304
+ const canonicalDirectory = await realpath(directory);
305
+ if (!(await stat(canonicalDirectory)).isDirectory()) throw new Error(`instruction scope is not a directory: ${directory}`);
306
+ for (const configuredName of state.instructionFiles) {
307
+ this.throwIfCancelled(context);
308
+ if (state.instructions.length >= MAX_INSTRUCTION_FILES) {
309
+ state.instructionsTruncated = true;
310
+ return;
311
+ }
312
+ const candidate = resolveInstructionPath(canonicalDirectory, configuredName);
313
+ const info = await lstat(candidate).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
314
+ if (!info) continue;
315
+ if (info.isSymbolicLink()) throw new Error(`instruction file must not be a symbolic link: ${candidate}`);
316
+ if (!info.isFile()) throw new Error(`instruction file is not a regular file: ${candidate}`);
317
+ const canonical = await realpath(candidate);
318
+ assertContainedPath(canonicalDirectory, canonical, "instruction file path");
319
+ if (info.size > MAX_INSTRUCTION_FILE_BYTES) throw new Error(`instruction file exceeds maximum size (${info.size} > ${MAX_INSTRUCTION_FILE_BYTES})`);
320
+ const remaining = state.instructionMaxBytes - state.instructionBytes;
321
+ if (info.size > remaining) {
322
+ state.instructionsTruncated = true;
323
+ return;
324
+ }
325
+ const content = await readRegularUtf8(canonical, Math.max(remaining, 1), "instruction file");
326
+ if (!content.text.trim()) continue;
327
+ state.instructionBytes += content.bytes;
328
+ state.instructions.push({
329
+ scope,
330
+ path: canonical,
331
+ bytes: content.bytes,
332
+ sha256: sha256(content.text),
333
+ content: content.text,
334
+ precedence: state.instructions.length + 1,
335
+ });
336
+ return;
337
+ }
338
+ }
339
+
340
+ async discoverSkills(state, options = {}, context = {}) {
341
+ const query = String(options.query || "").trim().toLowerCase();
342
+ const maxResults = clampInt(options.maxResults, 100, 1, MAX_SKILL_RESULTS);
343
+ const skills = [];
344
+ const warnings = [];
345
+ const seenEntrypoints = new Set();
346
+ const seenDirectories = new Set();
347
+ let visitedEntries = 0;
348
+ let truncated = false;
349
+
350
+ for (const configuredRoot of state.skillRoots.slice(0, MAX_SKILL_ROOTS)) {
351
+ this.throwIfCancelled(context);
352
+ const rootInfo = await lstat(configuredRoot).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
353
+ if (!rootInfo) continue;
354
+ const root = await realpath(configuredRoot);
355
+ const canonicalRootInfo = await stat(root);
356
+ if (!canonicalRootInfo.isDirectory()) throw new Error(`skill root is not a directory: ${this.displayPath(configuredRoot)}`);
357
+ assertAllowedPath(root, this.workspace, this.policy.unrestrictedPaths === true, "skill root");
358
+ const stack = [{ directory: root, depth: 0 }];
359
+ while (stack.length) {
360
+ this.throwIfCancelled(context);
361
+ const current = stack.pop();
362
+ if (seenDirectories.has(current.directory)) continue;
363
+ seenDirectories.add(current.directory);
364
+ visitedEntries += 1;
365
+ if (visitedEntries > MAX_SKILL_SCAN_ENTRIES) {
366
+ truncated = true;
367
+ break;
368
+ }
369
+ const entrypoint = await findSkillEntrypoint(current.directory);
370
+ if (entrypoint) {
371
+ const canonical = await realpath(entrypoint);
372
+ if (!seenEntrypoints.has(canonical)) {
373
+ seenEntrypoints.add(canonical);
374
+ try {
375
+ const summary = await summarizeSkill(canonical, root);
376
+ const haystack = `${summary.id}
377
+ ${summary.name}
378
+ ${summary.description}
379
+ ${summary.entrypoint}`.toLowerCase();
380
+ if (!query || haystack.includes(query)) {
381
+ skills.push(summary);
382
+ if (skills.length >= maxResults) {
383
+ truncated = true;
384
+ break;
385
+ }
386
+ }
387
+ } catch (error) {
388
+ if (warnings.length < 100) warnings.push({ entrypoint: canonical, message: boundedMessage(error) });
389
+ }
390
+ }
391
+ continue;
392
+ }
393
+ if (current.depth >= MAX_SKILL_SCAN_DEPTH) continue;
394
+ const handle = await opendir(current.directory);
395
+ for await (const entry of handle) {
396
+ this.throwIfCancelled(context);
397
+ visitedEntries += 1;
398
+ if (visitedEntries > MAX_SKILL_SCAN_ENTRIES) {
399
+ truncated = true;
400
+ break;
401
+ }
402
+ const child = join(current.directory, entry.name);
403
+ if (entry.isDirectory()) {
404
+ stack.push({ directory: child, depth: current.depth + 1 });
405
+ } else if (entry.isSymbolicLink()) {
406
+ const target = await realpath(child).catch(() => "");
407
+ if (!target) continue;
408
+ const targetInfo = await stat(target).catch(() => null);
409
+ if (!targetInfo?.isDirectory()) continue;
410
+ assertAllowedPath(target, this.workspace, this.policy.unrestrictedPaths === true, "skill symlink target");
411
+ stack.push({ directory: target, depth: current.depth + 1 });
412
+ }
413
+ }
414
+ if (truncated) break;
415
+ }
416
+ if (truncated && skills.length >= maxResults) break;
417
+ if (visitedEntries > MAX_SKILL_SCAN_ENTRIES) break;
418
+ }
419
+
420
+ skills.sort((left, right) => left.name.localeCompare(right.name) || left.entrypoint.localeCompare(right.entrypoint));
421
+ return { skills, warnings, truncated };
422
+ }
423
+ }
424
+
425
+ async function findScopeRoot({ targetDir, workspace, unrestricted }) {
426
+ const target = await realpath(targetDir);
427
+ const canonicalWorkspace = await realpath(workspace);
428
+ if (!unrestricted) assertContainedPath(canonicalWorkspace, target, "target path");
429
+ const fallback = isContainedPath(canonicalWorkspace, target) ? canonicalWorkspace : target;
430
+ let cursor = target;
431
+ while (true) {
432
+ const gitMarker = await lstat(join(cursor, ".git")).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
433
+ if (gitMarker) return cursor;
434
+ if (!unrestricted && cursor === canonicalWorkspace) break;
435
+ const parent = dirname(cursor);
436
+ if (parent === cursor) break;
437
+ if (!unrestricted && !isContainedPath(canonicalWorkspace, parent)) break;
438
+ cursor = parent;
439
+ }
440
+ return fallback;
441
+ }
442
+
443
+ function directoriesBetween(root, leaf) {
444
+ assertContainedPath(root, leaf, "target directory");
445
+ const result = [];
446
+ let cursor = leaf;
447
+ while (true) {
448
+ result.push(cursor);
449
+ if (cursor === root) break;
450
+ const parent = dirname(cursor);
451
+ if (parent === cursor) throw new Error("failed to construct instruction precedence chain");
452
+ cursor = parent;
453
+ }
454
+ return result.reverse();
455
+ }
456
+
457
+ function defaultSkillRoots(directories, home, unrestricted) {
458
+ const roots = [...directories].reverse().map((directory) => resolve(directory, ".agents", "skills"));
459
+ if (unrestricted && home) roots.push(resolve(home, ".agents", "skills"));
460
+ if (unrestricted && process.platform !== "win32") roots.push(resolve("/etc/codex/skills"));
461
+ return [...new Set(roots)];
462
+ }
463
+
464
+ async function readOptionalConfig(configPath, allowedRoot, rejectPathAliases) {
465
+ const content = await readOptionalRegularUtf8(configPath, MAX_CONFIG_BYTES, "agent config");
466
+ if (!content) return null;
467
+ const [canonical, canonicalAllowedRoot] = await Promise.all([realpath(configPath), realpath(allowedRoot)]);
468
+ assertContainedPath(canonicalAllowedRoot, canonical, "agent config path");
469
+ if (rejectPathAliases && canonical !== resolve(configPath)) throw new Error("agent config path must not traverse symbolic links");
470
+ let parsed;
471
+ try {
472
+ parsed = JSON.parse(content.text);
473
+ } catch {
474
+ throw new Error(`agent config is not valid JSON: ${configPath}`);
475
+ }
476
+ return normalizeConfig(parsed, configPath);
477
+ }
478
+
479
+ function normalizeConfig(value, configPath) {
480
+ if (!isPlainRecord(value)) throw new Error(`agent config must be a JSON object: ${configPath}`);
481
+ for (const key of Object.keys(value)) if (!CONFIG_KEYS.has(key)) throw new Error(`unknown agent config field '${key}': ${configPath}`);
482
+ if (value.version !== 1) throw new Error(`agent config version must be 1: ${configPath}`);
483
+ const result = { modelInstructionsFile: null, instructionFiles: null, instructionMaxBytes: null, skillRoots: null, commands: new Map() };
484
+ if (value.model_instructions_file !== undefined) {
485
+ result.modelInstructionsFile = requiredString(value.model_instructions_file, "model_instructions_file");
486
+ }
487
+ if (value.instruction_files !== undefined) {
488
+ result.instructionFiles = validateInstructionFiles(value.instruction_files, configPath);
489
+ }
490
+ if (value.instruction_max_bytes !== undefined) {
491
+ if (!Number.isInteger(value.instruction_max_bytes) || value.instruction_max_bytes < 1024 || value.instruction_max_bytes > MAX_TOTAL_INSTRUCTION_BYTES) {
492
+ throw new Error(`instruction_max_bytes must be an integer from 1024 to ${MAX_TOTAL_INSTRUCTION_BYTES}: ${configPath}`);
493
+ }
494
+ result.instructionMaxBytes = value.instruction_max_bytes;
495
+ }
496
+ if (value.skill_roots !== undefined) {
497
+ result.skillRoots = validateStringArray(value.skill_roots, "skill_roots", MAX_SKILL_ROOTS, MAX_COMMAND_ARGUMENT_BYTES);
498
+ }
499
+ if (value.commands !== undefined) {
500
+ if (!isPlainRecord(value.commands)) throw new Error(`agent config commands must be an object: ${configPath}`);
501
+ for (const [name, definition] of Object.entries(value.commands)) {
502
+ if (!COMMAND_NAME_PATTERN.test(name)) throw new Error(`invalid registered command name '${name}': ${configPath}`);
503
+ if (definition === null) {
504
+ result.commands.set(name, null);
505
+ continue;
506
+ }
507
+ result.commands.set(name, normalizeCommand(definition, name, configPath));
508
+ }
509
+ }
510
+ return result;
511
+ }
512
+
513
+ function normalizeCommand(value, name, configPath) {
514
+ if (!isPlainRecord(value)) throw new Error(`registered command '${name}' must be an object: ${configPath}`);
515
+ for (const key of Object.keys(value)) if (!COMMAND_KEYS.has(key)) throw new Error(`unknown field '${key}' for registered command '${name}': ${configPath}`);
516
+ const argv = validateStringArray(value.argv, `commands.${name}.argv`, MAX_COMMAND_ARGV, MAX_COMMAND_ARGUMENT_BYTES);
517
+ if (!argv.length) throw new Error(`registered command '${name}' requires a non-empty argv: ${configPath}`);
518
+ const description = typeof value.description === "string" ? value.description.trim() : "";
519
+ if (!description || description.length > 1000) throw new Error(`registered command '${name}' requires a description of at most 1000 characters: ${configPath}`);
520
+ const cwd = value.cwd === undefined ? "." : requiredString(value.cwd, `commands.${name}.cwd`);
521
+ const timeoutSeconds = clampInt(value.timeout_seconds, 120, 1, 600);
522
+ if (value.timeout_seconds !== undefined && timeoutSeconds !== value.timeout_seconds) {
523
+ throw new Error(`registered command '${name}' timeout_seconds must be an integer from 1 to 600: ${configPath}`);
524
+ }
525
+ if (value.allow_extra_args !== undefined && typeof value.allow_extra_args !== "boolean") {
526
+ throw new Error(`registered command '${name}' allow_extra_args must be boolean: ${configPath}`);
527
+ }
528
+ return {
529
+ description,
530
+ argv,
531
+ cwd,
532
+ timeoutSeconds,
533
+ allowExtraArgs: value.allow_extra_args === true,
534
+ };
535
+ }
536
+
537
+ function validateInstructionFiles(value, configPath) {
538
+ const files = validateStringArray(value, "instruction_files", 32, 64 * 1024);
539
+ if (!files.length) throw new Error(`instruction_files must not be empty: ${configPath}`);
540
+ for (const name of files) resolveInstructionPath("/", name);
541
+ return files;
542
+ }
543
+
544
+ function resolveInstructionPath(directory, configuredName) {
545
+ const raw = requiredString(configuredName, "instruction file name");
546
+ if (raw.includes("\0") || isAbsolute(raw)) throw new Error(`instruction file path must be relative: ${raw}`);
547
+ const candidate = resolve(directory, raw);
548
+ assertContainedPath(resolve(directory), candidate, "instruction file path");
549
+ return candidate;
550
+ }
551
+
552
+ function resolveConfiguredPath(configuredPath, baseDir, home, workspace, unrestricted) {
553
+ const raw = requiredString(configuredPath, "configured path");
554
+ if (raw.includes("\0")) throw new Error("configured path contains a NUL byte");
555
+ let expanded = raw;
556
+ if (raw === "~" || raw.startsWith(`~${sep}`) || raw.startsWith("~/") || raw.startsWith("~\\")) {
557
+ if (!home) throw new Error("HOME or USERPROFILE is required to expand '~'");
558
+ expanded = raw === "~" ? home : join(home, raw.slice(2));
559
+ }
560
+ const candidate = isAbsolute(expanded) ? resolve(expanded) : resolve(baseDir, expanded);
561
+ assertAllowedPath(candidate, workspace, unrestricted, "configured path");
562
+ return candidate;
563
+ }
564
+
565
+ async function findSkillEntrypoint(directory) {
566
+ for (const name of ["SKILL.md", "skill.md"]) {
567
+ const candidate = join(directory, name);
568
+ const info = await lstat(candidate).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
569
+ if (!info) continue;
570
+ if (info.isSymbolicLink()) throw new Error(`skill entrypoint must not be a symbolic link: ${candidate}`);
571
+ if (!info.isFile()) throw new Error(`skill entrypoint is not a regular file: ${candidate}`);
572
+ return candidate;
573
+ }
574
+ return "";
575
+ }
576
+
577
+ async function summarizeSkill(entrypoint, sourceRoot) {
578
+ const content = await readRegularUtf8(entrypoint, MAX_SKILL_ENTRY_BYTES, "skill entrypoint");
579
+ const metadata = parseSkillMetadata(content.text);
580
+ if (!metadata.name || !metadata.description) throw new Error("SKILL.md front matter requires non-empty name and description fields");
581
+ const directory = dirname(entrypoint);
582
+ const name = metadata.name.slice(0, 200);
583
+ return {
584
+ id: `skill_${sha256(entrypoint).slice(0, 16)}`,
585
+ name,
586
+ description: metadata.description.slice(0, 1000),
587
+ entrypoint,
588
+ directory,
589
+ sourceRoot,
590
+ bytes: content.bytes,
591
+ sha256: sha256(content.text),
592
+ };
593
+ }
594
+
595
+ export function parseSkillMetadata(content) {
596
+ const text = String(content || "");
597
+ if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) return {};
598
+ const lines = text.split(/\r?\n/);
599
+ let end = -1;
600
+ for (let index = 1; index < Math.min(lines.length, 200); index += 1) {
601
+ if (lines[index].trim() === "---") {
602
+ end = index;
603
+ break;
604
+ }
605
+ }
606
+ if (end === -1) return {};
607
+ const metadata = {};
608
+ for (const line of lines.slice(1, end)) {
609
+ const match = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(line);
610
+ if (!match) continue;
611
+ const key = match[1].toLowerCase();
612
+ if (key !== "name" && key !== "description") continue;
613
+ metadata[key] = unquoteScalar(match[2].trim());
614
+ }
615
+ return metadata;
616
+ }
617
+
618
+
619
+ async function listSkillFiles(root, maxFiles, context, throwIfCancelled) {
620
+ const files = [];
621
+ const stack = [{ directory: root, depth: 0 }];
622
+ let visited = 0;
623
+ let truncated = false;
624
+ while (stack.length) {
625
+ throwIfCancelled(context);
626
+ const current = stack.pop();
627
+ const handle = await opendir(current.directory);
628
+ for await (const entry of handle) {
629
+ throwIfCancelled(context);
630
+ visited += 1;
631
+ if (visited > MAX_SKILL_SCAN_ENTRIES) {
632
+ truncated = true;
633
+ break;
634
+ }
635
+ const full = join(current.directory, entry.name);
636
+ const rel = relative(root, full).split(sep).join("/");
637
+ if (entry.isDirectory()) {
638
+ if (current.depth < MAX_SKILL_SCAN_DEPTH) stack.push({ directory: full, depth: current.depth + 1 });
639
+ else truncated = true;
640
+ } else if (entry.isFile()) {
641
+ const info = await lstat(full);
642
+ files.push({ path: rel, bytes: info.size, type: "file" });
643
+ } else if (entry.isSymbolicLink()) {
644
+ files.push({ path: rel, bytes: 0, type: "symlink" });
645
+ }
646
+ if (files.length >= maxFiles) {
647
+ truncated = true;
648
+ break;
649
+ }
650
+ }
651
+ if (truncated && (files.length >= maxFiles || visited > MAX_SKILL_SCAN_ENTRIES)) break;
652
+ }
653
+ files.sort((left, right) => left.path.localeCompare(right.path));
654
+ return { files, truncated };
655
+ }
656
+
657
+ function capabilityFingerprint(state, skills) {
658
+ return sha256(JSON.stringify({
659
+ configs: state.configFiles,
660
+ instructions: [state.modelInstructions?.sha256 || "", ...state.instructions.map((item) => item.sha256)],
661
+ skills: skills.map((skill) => [skill.id, skill.sha256]),
662
+ commands: [...state.commands.values()].map((command) => [command.name, command.argv]),
663
+ }));
664
+ }
665
+
666
+ function relevanceScore(task, candidate) {
667
+ const taskTokens = tokenize(task);
668
+ const candidateTokens = tokenize(candidate);
669
+ if (!taskTokens.size || !candidateTokens.size) return 0;
670
+ let score = 0;
671
+ for (const token of taskTokens) {
672
+ if (candidateTokens.has(token)) score += token.length >= 6 ? 2 : 1;
673
+ }
674
+ const taskLower = task.toLowerCase();
675
+ const candidateLower = candidate.toLowerCase();
676
+ if (candidateLower.includes(taskLower) || taskLower.includes(candidateLower)) score += 4;
677
+ return score;
678
+ }
679
+
680
+ function tokenize(value) {
681
+ const text = String(value || "").toLowerCase();
682
+ const tokens = new Set();
683
+ for (const raw of text.match(/[a-z0-9_][a-z0-9_.-]{1,}/g) || []) {
684
+ const token = raw.replace(/^[.-]+|[.-]+$/g, "");
685
+ if (token.length >= 2 && !TOKEN_STOP_WORDS.has(token)) tokens.add(token);
686
+ }
687
+ for (const sequence of text.match(/[\p{Script=Han}]{1,}/gu) || []) {
688
+ if (!TOKEN_STOP_WORDS.has(sequence)) tokens.add(sequence);
689
+ const minimumSize = sequence.length === 1 ? 1 : 2;
690
+ for (let size = minimumSize; size <= Math.min(3, sequence.length); size += 1) {
691
+ for (let index = 0; index + size <= sequence.length; index += 1) {
692
+ const token = sequence.slice(index, index + size);
693
+ if (!TOKEN_STOP_WORDS.has(token)) tokens.add(token);
694
+ }
695
+ }
696
+ }
697
+ return tokens;
698
+ }
699
+
700
+ function recommendTools(task, hasCommands, hasSkills) {
701
+ const lower = task.toLowerCase();
702
+ const tools = ["agent_context"];
703
+ if (hasSkills) tools.push("load_local_skill");
704
+ if (hasCommands) tools.push("run_local_command");
705
+ if (/browser|chrome|edge|brave|网页|浏览器|表单|网站/.test(lower)) tools.push("browser_status", "browser_list_tabs", "browser_inspect_page", "browser_action", "browser_fill_form");
706
+ if (/app|application|gui|window|应用|软件|窗口|界面/.test(lower)) tools.push("list_local_applications", "inspect_local_application", "operate_local_application");
707
+ if (/git|commit|branch|diff|仓库|提交|分支/.test(lower)) tools.push("git_status", "git_diff");
708
+ if (/test|build|lint|command|terminal|测试|构建|命令|终端/.test(lower)) tools.push(hasCommands ? "run_local_command" : "run_process");
709
+ if (/file|code|source|edit|write|文件|代码|源码|修改|写入/.test(lower)) tools.push("read_file", "search_text", "edit_file", "apply_patch");
710
+ return [...new Set(tools)];
711
+ }
712
+
713
+ function publicSkill(skill, displayPath) {
714
+ return {
715
+ id: skill.id,
716
+ name: skill.name,
717
+ description: skill.description,
718
+ entrypoint: displayPath(skill.entrypoint),
719
+ source_root: displayPath(skill.sourceRoot),
720
+ bytes: skill.bytes,
721
+ sha256: skill.sha256,
722
+ };
723
+ }
724
+
725
+ function contextSkillSummaries(skills, displayPath, maxSkills, budgetChars) {
726
+ const selected = [];
727
+ let used = 0;
728
+ for (const skill of skills) {
729
+ if (selected.length >= maxSkills) return { skills: selected, truncated: true };
730
+ const item = publicSkill(skill, displayPath);
731
+ const fullSize = JSON.stringify(item).length;
732
+ if (used + fullSize <= budgetChars) {
733
+ selected.push(item);
734
+ used += fullSize;
735
+ continue;
736
+ }
737
+ const withoutDescription = { ...item, description: "", description_truncated: true };
738
+ const baseSize = JSON.stringify(withoutDescription).length;
739
+ const available = budgetChars - used - baseSize;
740
+ if (available >= 32) {
741
+ selected.push({ ...withoutDescription, description: item.description.slice(0, available) });
742
+ }
743
+ return { skills: selected, truncated: true };
744
+ }
745
+ return { skills: selected, truncated: false };
746
+ }
747
+
748
+ function publicSkillWarnings(warnings, displayPath) {
749
+ return warnings.map((warning) => ({ entrypoint: displayPath(warning.entrypoint), message: warning.message }));
750
+ }
751
+
752
+ function publicCommands(commands, displayPath) {
753
+ return [...commands.values()]
754
+ .sort((left, right) => left.name.localeCompare(right.name))
755
+ .map((command) => ({
756
+ name: command.name,
757
+ description: command.description,
758
+ argv: [...command.argv],
759
+ cwd: displayPath(command.cwd),
760
+ timeout_seconds: command.timeoutSeconds,
761
+ allow_extra_args: command.allowExtraArgs,
762
+ source: displayPath(command.source),
763
+ }));
764
+ }
765
+
766
+ function renderEffectiveInstructions(instructions, displayPath) {
767
+ return instructions.map((item) => [
768
+ `--- BEGIN ${displayPath(item.path)} (precedence ${item.precedence}) ---`,
769
+ item.content,
770
+ `--- END ${displayPath(item.path)} ---`,
771
+ ].join("\n")).join("\n\n");
772
+ }
773
+
774
+ async function readOptionalRegularUtf8(filePath, maxBytes, label) {
775
+ const info = await lstat(filePath).catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
776
+ if (!info) return null;
777
+ if (info.isSymbolicLink()) throw new Error(`${label} must not be a symbolic link: ${filePath}`);
778
+ if (!info.isFile()) throw new Error(`${label} is not a regular file: ${filePath}`);
779
+ return readRegularUtf8(filePath, maxBytes, label);
780
+ }
781
+
782
+ async function readRegularUtf8(filePath, maxBytes, label) {
783
+ const handle = await open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
784
+ try {
785
+ const info = await handle.stat();
786
+ if (!info.isFile()) throw new Error(`${label} is not a regular file: ${filePath}`);
787
+ if (info.size > maxBytes) throw new Error(`${label} exceeds maximum size (${info.size} > ${maxBytes})`);
788
+ const buffer = Buffer.alloc(info.size);
789
+ let offset = 0;
790
+ while (offset < buffer.length) {
791
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
792
+ if (!bytesRead) break;
793
+ offset += bytesRead;
794
+ }
795
+ let text;
796
+ try {
797
+ text = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
798
+ } catch {
799
+ throw new Error(`${label} is not valid UTF-8 text: ${filePath}`);
800
+ }
801
+ return { text, bytes: offset };
802
+ } finally {
803
+ await handle.close();
804
+ }
805
+ }
806
+
807
+ function validateStringArray(value, label, maxItems, maxBytes) {
808
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array of strings`);
809
+ if (value.length > maxItems) throw new Error(`${label} contains more than ${maxItems} items`);
810
+ let bytes = 0;
811
+ return value.map((item, index) => {
812
+ if (typeof item !== "string" || !item.length || item.includes("\0")) throw new Error(`${label}[${index}] must be a non-empty string without NUL bytes`);
813
+ bytes += Buffer.byteLength(item);
814
+ if (bytes > maxBytes) throw new Error(`${label} exceeds maximum encoded size (${maxBytes} bytes)`);
815
+ return item;
816
+ });
817
+ }
818
+
819
+ function assertAllowedPath(candidate, workspace, unrestricted, label) {
820
+ if (!unrestricted) assertContainedPath(workspace, candidate, label);
821
+ }
822
+
823
+ function assertContainedPath(root, target, label) {
824
+ if (isContainedPath(root, target)) return;
825
+ throw new Error(`${label} is outside the configured workspace`);
826
+ }
827
+
828
+ function isContainedPath(root, target) {
829
+ const rel = relative(resolve(root), resolve(target));
830
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
831
+ }
832
+
833
+ function isPlainRecord(value) {
834
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
835
+ }
836
+
837
+ function boundedMessage(error) {
838
+ return String(error?.message || error || "invalid local skill").replace(/[\r\n]+/g, " ").slice(0, 1000);
839
+ }
840
+
841
+ function requiredString(value, label) {
842
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required`);
843
+ return value.trim();
844
+ }
845
+
846
+ function unquoteScalar(value) {
847
+ if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
848
+ return value.slice(1, -1);
849
+ }
850
+ return value;
851
+ }
852
+
853
+ function clampInt(value, fallback, min, max) {
854
+ if (value === undefined || value === null || value === "") return fallback;
855
+ const parsed = Number(value);
856
+ if (!Number.isInteger(parsed)) return fallback;
857
+ return Math.min(Math.max(parsed, min), max);
858
+ }
859
+
860
+ function sha256(value) {
861
+ return createHash("sha256").update(String(value)).digest("hex");
862
+ }