machine-bridge-mcp 0.9.0 → 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.
package/docs/RELEASING.md CHANGED
@@ -6,7 +6,7 @@ The release invariant is:
6
6
  - `v<package version>` points to that same commit locally and on GitHub.
7
7
  - A final GitHub Release exists for the tag.
8
8
  - The GitHub Release contains the npm tarball generated from that commit.
9
- - `package.json`, `package-lock.json`, and the Worker-reported version agree.
9
+ - `package.json`, `package-lock.json`, the Worker-reported version, and `browser-extension/manifest.json` (`version`/`version_name`) agree.
10
10
  - Every release-relevant change since the prior version tag has a higher package version and matching CHANGELOG section.
11
11
  - The same reviewed change is present on GitHub and in a new npm version.
12
12
 
@@ -14,7 +14,7 @@ The release invariant is:
14
14
 
15
15
  ## Prepare a version
16
16
 
17
- 1. Set the new version without creating an automatic npm tag:
17
+ 1. Set the new version without creating an automatic npm tag. The npm version hook synchronizes the Worker and packaged browser-extension versions:
18
18
 
19
19
  ```sh
20
20
  npm version <version> --no-git-tag-version
package/docs/TESTING.md CHANGED
@@ -17,7 +17,8 @@ The suite includes:
17
17
  - generated Cloudflare Worker types and strict TypeScript checking, including unused-local and unused-parameter rejection;
18
18
  - syntax validation for every shipped JavaScript entry point;
19
19
  - shared tool-catalog schema, annotation, and profile-inventory checks;
20
- - hierarchical agent-context precedence, custom instruction ordering, local skill discovery/loading, command override/removal, direct argv argument handling, timeout ceilings, and execution-profile denial;
20
+ - global `model_instructions_file` injection in stdio/remote initialization, hierarchical agent-context precedence, live skill rescanning/fingerprints, automatic task ranking/loading, command override/removal, direct argv handling, timeout ceilings, and execution-profile denial;
21
+ - machine-level browser-broker ownership/client proxying, authenticated extension origin/subprotocol, non-cacheable local pairing, pairing-token non-disclosure, resource-backed upload routing, and broker result redaction;
21
22
  - canonical path and symbolic-link escape tests;
22
23
  - relative-path privacy and error-path redaction tests;
23
24
  - atomic create/update, optimistic hash, exact edit, and patch transaction tests;
@@ -42,8 +43,8 @@ The suite includes:
42
43
  - deterministic relay connection lifecycle coverage for transport construction/error/deadline, pre-handshake `welcome` validation, authenticated `hello_ack` readiness, identity/version mismatch, retryable Worker handshake errors, fatal protocol errors, autonomous outage-reminder backoff, handshake and heartbeat timeout, brief-outage suppression, sustained-outage escalation, recovery summaries, and supersession;
43
44
  - shared no-follow bounded-file reads for normal files, over-limit data, directories, and symbolic links;
44
45
  - CLI parsing, policy profiles, and client configuration boundaries;
45
- - live stdio MCP initialization, discovery, calls, rich content, sessions, cancellation, managed-job acceptance, and a detached job/finally phase that survives stdio shutdown;
46
- - live local Worker OAuth registration, consent, PKCE, token replay rejection, throttling, CORS, protocol negotiation, dynamic tool advertisement, rich content, daemon replacement, cancellation, malformed daemon JSON/non-object rejection, duplicate hello rejection, and unknown-message closure.
46
+ - live stdio MCP initialization with session instructions, capability resolution, discovery, calls, rich content, sessions, cancellation, managed-job acceptance, and a detached job/finally phase that survives stdio shutdown;
47
+ - live local Worker OAuth registration, consent, PKCE, token replay rejection, throttling, CORS, protocol negotiation, daemon-backed session bootstrap, dynamic tool advertisement, rich content, daemon replacement, cancellation, malformed daemon JSON/non-object rejection, duplicate hello rejection, and unknown-message closure.
47
48
 
48
49
  ## Additional release checks
49
50
 
@@ -63,7 +64,7 @@ GitHub Actions executes the main suite on Linux, macOS, and Windows using the pi
63
64
  ## Test design rules
64
65
 
65
66
  - Tests should exercise the public boundary rather than only helper functions when practical.
66
- - Every permission-expanding feature needs a denial test.
67
+ - Every permission-expanding feature needs a denial test. Browser/application features also require a token/value/content non-disclosure assertion.
67
68
  - Every bounded resource needs an over-limit test.
68
69
  - Every multi-stage mutation needs a no-partial-commit test.
69
70
  - Every remote call correlation change needs daemon replacement and cancellation coverage.
@@ -78,7 +79,7 @@ Run `npm run privacy:check` before committing and before packaging. Developers s
78
79
 
79
80
  ## Package manifest
80
81
 
81
- `npm run package:test` executes a real silent `npm pack --dry-run --json`, requires clean parseable JSON, rejects sensitive local artifacts and credential-like file classes, and verifies that privacy/engineering guidance, the runtime/relay/secure-file modules, contribution discipline, and privacy/release-impact checkers are present in the published package. `npm run install:test` packs the real tarball, installs it into an isolated global prefix with the documented npm 12 options, rejects blocked-script warnings, confirms optional `fsevents` is absent, and runs the installed CLI.
82
+ `npm run package:test` executes a real silent `npm pack --dry-run --json`, requires clean parseable JSON, rejects sensitive local artifacts and credential-like file classes, and verifies that privacy/engineering guidance, the runtime/relay/secure-file/browser/app modules, packaged browser extension, contribution discipline, and privacy/release-impact checkers are present in the published package. `npm run install:test` packs the real tarball, installs it into an isolated global prefix with the documented npm 12 options, rejects blocked-script warnings, confirms optional `fsevents` is absent, and runs the installed CLI.
82
83
 
83
84
  The stdio integration test also sends an oversized line, verifies bounded rejection, and confirms that the next valid request is still processed.
84
85
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "0.9.0",
4
- "description": "Cross-client MCP bridge for canonical local automation, files, Git, SSH resources, managed jobs, and processes over stdio or an OAuth-protected remote relay.",
3
+ "version": "0.10.0",
4
+ "description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "engines": {
@@ -16,6 +16,7 @@
16
16
  "mbm",
17
17
  "mbm.cmd",
18
18
  "bin",
19
+ "browser-extension",
19
20
  "src/local",
20
21
  "src/shared",
21
22
  "src/worker/index.ts",
@@ -38,13 +39,13 @@
38
39
  "self-test": "node tests/local-self-test.mjs",
39
40
  "version:sync": "node scripts/sync-worker-version.mjs",
40
41
  "version:check": "node scripts/sync-worker-version.mjs --check",
41
- "version": "node scripts/sync-worker-version.mjs && git add package.json package-lock.json src/worker/index.ts",
42
+ "version": "node scripts/sync-worker-version.mjs && git add package.json package-lock.json src/worker/index.ts browser-extension/manifest.json",
42
43
  "prepack": "npm run version:check && npm run privacy:check && npm run release-impact:check",
43
44
  "prepublishOnly": "npm run check && npm run version:check && npm run release:check",
44
45
  "worker:types": "wrangler types src/worker/worker-configuration.d.ts",
45
46
  "typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
46
- "syntax": "sh -n mbm && node --check bin/machine-mcp.mjs && node --check src/local/cli.mjs && node --check src/local/runtime.mjs && node --check src/local/agent-context.mjs && node --check src/local/relay-connection.mjs && node --check src/local/secure-file.mjs && node --check src/local/patch.mjs && node --check src/local/process-sessions.mjs && node --check src/local/tools.mjs && node --check src/local/stdio.mjs && node --check src/local/state.mjs && node --check src/local/shell.mjs && node --check src/local/service.mjs && node --check src/local/log.mjs && node --check src/local/atomic-fs.mjs && node --check src/local/ssh-key.mjs && node --check src/local/resource-operations.mjs && node --check src/local/full-access-test.mjs && node --check src/local/managed-jobs.mjs && node --check src/local/job-runner.mjs && node --check scripts/sync-worker-version.mjs && node --check scripts/privacy-check.mjs && node --check scripts/release-impact-check.mjs && node --check scripts/network-retry.mjs && node --check scripts/release-state.mjs && node --check scripts/github-release.mjs && node --check tests/worker-integration-test.mjs && node --check tests/stdio-integration-test.mjs && node --check tests/catalog-test.mjs && node --check tests/agent-context-test.mjs && node --check tests/local-self-test.mjs && node --check tests/runtime-self-test.mjs && node --check tests/managed-jobs-test.mjs && node --check tests/ssh-key-test.mjs && node --check tests/full-access-test.mjs && node --check tests/atomic-fs-test.mjs && node --check tests/package-test.mjs && node --check tests/release-impact-test.mjs && node --check tests/release-state-test.mjs && node --check tests/privacy-test.mjs && node --check tests/network-retry-test.mjs && node --check tests/relay-connection-test.mjs && node --check tests/install-smoke-test.mjs && node --check tests/secure-file-test.mjs && node --check tests/architecture-test.mjs",
47
- "check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run typecheck && npm run syntax && npm run catalog:test && npm run agent-context:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
47
+ "syntax": "sh -n mbm && node --check bin/machine-mcp.mjs && node --check src/local/cli.mjs && node --check src/local/runtime.mjs && node --check src/local/agent-context.mjs && node --check src/local/browser-bridge.mjs && node --check src/local/app-automation.mjs && node --check src/local/relay-connection.mjs && node --check src/local/secure-file.mjs && node --check src/local/patch.mjs && node --check src/local/process-sessions.mjs && node --check src/local/tools.mjs && node --check src/local/stdio.mjs && node --check src/local/state.mjs && node --check src/local/shell.mjs && node --check src/local/service.mjs && node --check src/local/log.mjs && node --check src/local/atomic-fs.mjs && node --check src/local/ssh-key.mjs && node --check src/local/resource-operations.mjs && node --check src/local/full-access-test.mjs && node --check src/local/managed-jobs.mjs && node --check src/local/job-runner.mjs && node --check scripts/sync-worker-version.mjs && node --check scripts/privacy-check.mjs && node --check scripts/release-impact-check.mjs && node --check scripts/network-retry.mjs && node --check scripts/release-state.mjs && node --check scripts/github-release.mjs && node --check tests/worker-integration-test.mjs && node --check tests/stdio-integration-test.mjs && node --check tests/catalog-test.mjs && node --check tests/agent-context-test.mjs && node --check tests/browser-bridge-test.mjs && node --check tests/app-automation-test.mjs && node --check tests/local-self-test.mjs && node --check tests/runtime-self-test.mjs && node --check tests/managed-jobs-test.mjs && node --check tests/ssh-key-test.mjs && node --check tests/full-access-test.mjs && node --check tests/atomic-fs-test.mjs && node --check tests/package-test.mjs && node --check tests/release-impact-test.mjs && node --check tests/release-state-test.mjs && node --check tests/privacy-test.mjs && node --check tests/network-retry-test.mjs && node --check tests/relay-connection-test.mjs && node --check tests/install-smoke-test.mjs && node --check tests/secure-file-test.mjs && node --check tests/architecture-test.mjs && node --check browser-extension/service-worker.js && node --check browser-extension/pairing.js && node --check browser-extension/page-automation.js",
48
+ "check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run typecheck && npm run syntax && npm run catalog:test && npm run agent-context:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
48
49
  "worker:dry-run": "wrangler deploy --dry-run",
49
50
  "worker:integration-test": "node tests/worker-integration-test.mjs",
50
51
  "release:check": "node scripts/github-release.mjs --check",
@@ -67,7 +68,9 @@
67
68
  "secure-file:test": "node tests/secure-file-test.mjs",
68
69
  "architecture:test": "node tests/architecture-test.mjs",
69
70
  "release-state:test": "node tests/release-state-test.mjs",
70
- "agent-context:test": "node tests/agent-context-test.mjs"
71
+ "agent-context:test": "node tests/agent-context-test.mjs",
72
+ "browser-bridge:test": "node tests/browser-bridge-test.mjs",
73
+ "app-automation:test": "node tests/app-automation-test.mjs"
71
74
  },
72
75
  "dependencies": {
73
76
  "wrangler": "4.110.0",
@@ -90,7 +93,10 @@
90
93
  "cross-platform",
91
94
  "ssh",
92
95
  "automation",
93
- "managed-jobs"
96
+ "managed-jobs",
97
+ "browser-automation",
98
+ "desktop-automation",
99
+ "agent-skills"
94
100
  ],
95
101
  "repository": {
96
102
  "type": "git",
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
8
  const packagePath = path.join(repoRoot, "package.json");
9
9
  const workerPath = path.join(repoRoot, "src", "worker", "index.ts");
10
+ const extensionManifestPath = path.join(repoRoot, "browser-extension", "manifest.json");
10
11
  const args = new Set(process.argv.slice(2));
11
12
  const checkOnly = args.has("--check");
12
13
  const log = message => process.stderr.write(`${message}\n`);
@@ -17,24 +18,38 @@ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(expected)) {
17
18
  fail(`package.json version is not a valid release version: ${JSON.stringify(expected)}`);
18
19
  }
19
20
 
20
- const source = readFileSync(workerPath, "utf8");
21
- const pattern = /const SERVER_VERSION = "([^"]+)";/;
22
- const match = source.match(pattern);
23
- if (!match) fail("Could not find `const SERVER_VERSION = \"...\";` in src/worker/index.ts");
21
+ const workerSource = readFileSync(workerPath, "utf8");
22
+ const workerPattern = /const SERVER_VERSION = "([^"]+)";/;
23
+ const workerMatch = workerSource.match(workerPattern);
24
+ if (!workerMatch) fail("Could not find `const SERVER_VERSION = \"...\";` in src/worker/index.ts");
24
25
 
25
- const current = match[1];
26
- if (current === expected) {
27
- log(`Worker version is in sync: ${expected}`);
28
- process.exit(0);
26
+ const extension = JSON.parse(readFileSync(extensionManifestPath, "utf8"));
27
+ const extensionVersion = expected.split(/[+-]/, 1)[0];
28
+ const extensionParts = extensionVersion.split(".").map((value) => Number(value));
29
+ if (extensionParts.length < 1 || extensionParts.length > 4 || extensionParts.some((value) => !Number.isInteger(value) || value < 0 || value > 65535)) {
30
+ fail(`package version cannot be represented as a Chromium extension version: ${expected}`);
29
31
  }
32
+ const mismatches = [];
33
+ if (workerMatch[1] !== expected) mismatches.push(`Worker=${workerMatch[1]}`);
34
+ if (String(extension.version || "") !== extensionVersion) mismatches.push(`browser-extension.version=${extension.version || "<missing>"}`);
35
+ if (String(extension.version_name || "") !== expected) mismatches.push(`browser-extension.version_name=${extension.version_name || "<missing>"}`);
30
36
 
37
+ if (!mismatches.length) {
38
+ log(`Runtime versions are in sync: ${expected}`);
39
+ process.exit(0);
40
+ }
31
41
  if (checkOnly) {
32
- fail(`Worker version mismatch: package.json=${expected}, src/worker/index.ts=${current}. Run npm run version:sync.`);
42
+ fail(`Version mismatch: package.json=${expected}; ${mismatches.join(", ")}. Run npm run version:sync.`);
33
43
  }
34
44
 
35
- const updated = source.replace(pattern, `const SERVER_VERSION = "${expected}";`);
36
- writeFileSync(workerPath, updated);
37
- log(`Updated Worker version: ${current} -> ${expected}`);
45
+ if (workerMatch[1] !== expected) {
46
+ writeFileSync(workerPath, workerSource.replace(workerPattern, `const SERVER_VERSION = "${expected}";`));
47
+ log(`Updated Worker version: ${workerMatch[1]} -> ${expected}`);
48
+ }
49
+ extension.version = extensionVersion;
50
+ extension.version_name = expected;
51
+ writeFileSync(extensionManifestPath, `${JSON.stringify(extension, null, 2)}\n`);
52
+ log(`Updated browser extension version: ${extensionVersion} (${expected})`);
38
53
 
39
54
  function fail(message) {
40
55
  console.error(message);
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
2
3
  import { lstat, open, opendir, realpath, stat } from "node:fs/promises";
3
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
5
 
@@ -21,7 +22,8 @@ const MAX_COMMANDS = 128;
21
22
  const MAX_COMMAND_ARGV = 128;
22
23
  const MAX_COMMAND_ARGUMENT_BYTES = 256 * 1024;
23
24
  const COMMAND_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
24
- const CONFIG_KEYS = new Set(["version", "instruction_files", "instruction_max_bytes", "skill_roots", "commands"]);
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"]);
25
27
  const COMMAND_KEYS = new Set(["description", "argv", "cwd", "timeout_seconds", "allow_extra_args"]);
26
28
 
27
29
  export class AgentContextManager {
@@ -46,6 +48,12 @@ export class AgentContextManager {
46
48
  scope_root: this.displayPath(state.scopeRoot),
47
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",
48
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,
49
57
  instruction_files: state.instructions.map((item) => ({
50
58
  scope: item.scope,
51
59
  path: this.displayPath(item.path),
@@ -65,10 +73,84 @@ export class AgentContextManager {
65
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.",
66
74
  ],
67
75
  };
68
- if (includeContent) result.effective_instructions = renderEffectiveInstructions(state.instructions, this.displayPath);
76
+ if (includeContent) result.effective_instructions = renderEffectiveInstructions([...(state.modelInstructions ? [state.modelInstructions] : []), ...state.instructions], this.displayPath);
69
77
  return result;
70
78
  }
71
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
+
72
154
  async listLocalSkills(args = {}, context = {}) {
73
155
  const state = await this.discoverState(args.path || ".", context);
74
156
  const result = await this.discoverSkills(state, {
@@ -150,31 +232,44 @@ export class AgentContextManager {
150
232
  instructionMaxBytes: DEFAULT_INSTRUCTION_MAX_BYTES,
151
233
  skillRoots: defaultSkillRoots(directories, this.home, this.policy.unrestrictedPaths === true),
152
234
  commands: new Map(),
235
+ modelInstructionsFile: "",
236
+ modelInstructions: null,
153
237
  configFiles: [],
154
238
  instructions: [],
155
239
  instructionBytes: 0,
156
240
  instructionsTruncated: false,
157
241
  };
158
242
 
159
- if (this.policy.unrestrictedPaths === true && this.home) {
243
+ if (this.home) {
160
244
  const globalConfig = join(this.home, GLOBAL_CONFIG_RELATIVE_PATH);
161
245
  const config = await readOptionalConfig(globalConfig, this.home, false);
162
- if (config) this.applyConfig(state, config, globalConfig, this.home);
163
- if (this.codexHome) await this.collectDirectoryInstruction(state, this.codexHome, context, "global");
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");
164
255
  }
165
256
 
166
257
  for (const directory of directories) {
167
258
  this.throwIfCancelled(context);
168
259
  const configPath = join(directory, CONFIG_RELATIVE_PATH);
169
260
  const config = await readOptionalConfig(configPath, directory, true);
170
- if (config) this.applyConfig(state, config, configPath, directory);
261
+ if (config) this.applyConfig(state, config, configPath, directory, { global: false });
171
262
  await this.collectDirectoryInstruction(state, directory, context, "project");
172
263
  }
173
264
  return state;
174
265
  }
175
266
 
176
- applyConfig(state, config, configPath, baseDir) {
267
+ applyConfig(state, config, configPath, baseDir, { global = false } = {}) {
177
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
+ }
178
273
  if (config.instructionFiles) state.instructionFiles = [...config.instructionFiles];
179
274
  if (config.instructionMaxBytes !== null) {
180
275
  state.instructionMaxBytes = config.instructionMaxBytes;
@@ -385,7 +480,10 @@ function normalizeConfig(value, configPath) {
385
480
  if (!isPlainRecord(value)) throw new Error(`agent config must be a JSON object: ${configPath}`);
386
481
  for (const key of Object.keys(value)) if (!CONFIG_KEYS.has(key)) throw new Error(`unknown agent config field '${key}': ${configPath}`);
387
482
  if (value.version !== 1) throw new Error(`agent config version must be 1: ${configPath}`);
388
- const result = { instructionFiles: null, instructionMaxBytes: null, skillRoots: null, commands: new Map() };
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
+ }
389
487
  if (value.instruction_files !== undefined) {
390
488
  result.instructionFiles = validateInstructionFiles(value.instruction_files, configPath);
391
489
  }
@@ -556,6 +654,62 @@ async function listSkillFiles(root, maxFiles, context, throwIfCancelled) {
556
654
  return { files, truncated };
557
655
  }
558
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
+
559
713
  function publicSkill(skill, displayPath) {
560
714
  return {
561
715
  id: skill.id,
@@ -626,7 +780,7 @@ async function readOptionalRegularUtf8(filePath, maxBytes, label) {
626
780
  }
627
781
 
628
782
  async function readRegularUtf8(filePath, maxBytes, label) {
629
- const handle = await open(filePath, "r");
783
+ const handle = await open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
630
784
  try {
631
785
  const info = await handle.stat();
632
786
  if (!info.isFile()) throw new Error(`${label} is not a regular file: ${filePath}`);