@4fu/arbor-environment-dir 0.0.0-stage → 0.1.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/index.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @4fu/arbor-environment-dir - the default `Environment`: one shared `/env`
3
+ * directory, with a revision per item, validation on install and deltas for
4
+ * live windows (docs/environment.md).
5
+ */
6
+ export { BUILTIN_SKILLS, BUILTIN_SKILL_PATH, DirectoryEnvironment, type DirectoryEnvironmentOptions, } from "./directory-environment.ts";
7
+ export { StdioMcpServer, type StdioServerOptions, type ServerFunction } from "./mcp.ts";
package/index.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @4fu/arbor-environment-dir - the default `Environment`: one shared `/env`
3
+ * directory, with a revision per item, validation on install and deltas for
4
+ * live windows (docs/environment.md).
5
+ */
6
+ export { BUILTIN_SKILLS, BUILTIN_SKILL_PATH, DirectoryEnvironment, } from "./directory-environment.js";
7
+ export { StdioMcpServer } from "./mcp.js";
package/mcp.d.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A minimal MCP client over stdio: one server process, newline-delimited
3
+ * JSON-RPC 2.0 (docs/environment.md#using-a-skill-or-a-server).
4
+ *
5
+ * arbor needs three things from a server - start it, list its functions, call
6
+ * one - so this is the whole protocol it speaks: `initialize`,
7
+ * `notifications/initialized`, `tools/list` and `tools/call`. A server that
8
+ * answers those is usable; anything else an MCP server can do stays with the
9
+ * model, which reads and reinstalls the server like any environment item.
10
+ *
11
+ * A secret is named by the configuration and its value comes from the host, so
12
+ * a server never holds one and none is written anywhere.
13
+ */
14
+ import type { FunctionSpec } from "@4fu/arbor-interfaces";
15
+ export interface StdioServerOptions {
16
+ /** The item's name, which the module and its functions are named after. */
17
+ name: string;
18
+ /** The directory the command runs in: the item's own directory. */
19
+ dir: string;
20
+ command: string;
21
+ args?: readonly string[];
22
+ env?: Record<string, string>;
23
+ /** The names of the secrets it uses; their values come from `secretOf`. */
24
+ secrets?: readonly string[];
25
+ /** Where a secret's value comes from. A configuration names one, never holds it. */
26
+ secretOf?: (name: string) => string | undefined;
27
+ timeoutMs?: number;
28
+ }
29
+ /** One function a server lists, in the shape the catalog declares. */
30
+ export interface ServerFunction {
31
+ name: string;
32
+ description: string;
33
+ signature: string;
34
+ }
35
+ export declare class StdioMcpServer {
36
+ private readonly options;
37
+ private readonly timeoutMs;
38
+ private readonly child;
39
+ private readonly lines;
40
+ private readonly stderr;
41
+ private nextId;
42
+ private readonly waiting;
43
+ private closed;
44
+ private constructor();
45
+ /** Start a server and complete the MCP handshake. */
46
+ static start(options: StdioServerOptions): Promise<StdioMcpServer>;
47
+ /** The functions this server lists, in the shape the catalog declares. */
48
+ functions(): Promise<readonly FunctionSpec[]>;
49
+ /** Call one function and answer with its text. */
50
+ call(fn: string, args: Record<string, unknown>): Promise<string>;
51
+ /** Stop the server. A stopped server is started again on the next call. */
52
+ close(): Promise<void>;
53
+ private functionOf;
54
+ private notify;
55
+ private request;
56
+ private send;
57
+ private answer;
58
+ /** A server that died leaves no request hanging. */
59
+ private failAll;
60
+ }
package/mcp.js ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * A minimal MCP client over stdio: one server process, newline-delimited
3
+ * JSON-RPC 2.0 (docs/environment.md#using-a-skill-or-a-server).
4
+ *
5
+ * arbor needs three things from a server - start it, list its functions, call
6
+ * one - so this is the whole protocol it speaks: `initialize`,
7
+ * `notifications/initialized`, `tools/list` and `tools/call`. A server that
8
+ * answers those is usable; anything else an MCP server can do stays with the
9
+ * model, which reads and reinstalls the server like any environment item.
10
+ *
11
+ * A secret is named by the configuration and its value comes from the host, so
12
+ * a server never holds one and none is written anywhere.
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ import { createInterface } from "node:readline";
16
+ import { arborError } from "@4fu/arbor-errors";
17
+ /** The MCP protocol version this client speaks. */
18
+ const PROTOCOL_VERSION = "2024-11-05";
19
+ /** How long one request waits for an answer. */
20
+ const DEFAULT_TIMEOUT_MS = 20000;
21
+ export class StdioMcpServer {
22
+ options;
23
+ timeoutMs;
24
+ child;
25
+ lines;
26
+ stderr = [];
27
+ nextId = 1;
28
+ waiting = new Map();
29
+ closed = false;
30
+ constructor(options) {
31
+ this.options = options;
32
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
33
+ const secrets = {};
34
+ for (const name of options.secrets ?? []) {
35
+ const value = options.secretOf?.(name);
36
+ if (value !== undefined && value !== "")
37
+ secrets[name] = value;
38
+ }
39
+ this.child = spawn(options.command, [...(options.args ?? [])], {
40
+ cwd: options.dir,
41
+ env: { ...process.env, ...(options.env ?? {}), ...secrets },
42
+ stdio: ["pipe", "pipe", "pipe"],
43
+ });
44
+ this.child.stderr?.on("data", (chunk) => {
45
+ this.stderr.push(chunk.toString("utf8"));
46
+ if (this.stderr.length > 50)
47
+ this.stderr.shift();
48
+ });
49
+ this.child.on("error", (error) => this.failAll(error));
50
+ this.child.on("exit", (code) => this.failAll(new Error(`the server exited with code ${code ?? "null"}`)));
51
+ this.lines = createInterface({ input: this.child.stdout });
52
+ this.lines.on("line", (line) => this.answer(line));
53
+ }
54
+ /** Start a server and complete the MCP handshake. */
55
+ static async start(options) {
56
+ const server = new StdioMcpServer(options);
57
+ try {
58
+ await server.request("initialize", {
59
+ protocolVersion: PROTOCOL_VERSION,
60
+ capabilities: {},
61
+ clientInfo: { name: "arbor", version: "0.0.0" },
62
+ });
63
+ server.notify("notifications/initialized", {});
64
+ return server;
65
+ }
66
+ catch (error) {
67
+ await server.close();
68
+ throw error;
69
+ }
70
+ }
71
+ /** The functions this server lists, in the shape the catalog declares. */
72
+ async functions() {
73
+ const result = (await this.request("tools/list", {}));
74
+ const tools = Array.isArray(result.tools) ? result.tools : [];
75
+ return tools.map((tool) => this.functionOf(tool));
76
+ }
77
+ /** Call one function and answer with its text. */
78
+ async call(fn, args) {
79
+ const name = fn.startsWith(`${this.options.name}.`) ? fn.slice(this.options.name.length + 1) : fn;
80
+ const result = (await this.request("tools/call", { name, arguments: args }));
81
+ if (result.isError === true) {
82
+ throw arborError({
83
+ code: "refused",
84
+ rule: "mcp.call.failed",
85
+ message: `${this.options.name}.${name} 调用失败`,
86
+ nextAction: "检查参数后用 tool_describe 核对签名,再调用一次。",
87
+ details: { server: this.options.name, function: name },
88
+ });
89
+ }
90
+ const content = Array.isArray(result.content) ? result.content : [];
91
+ return content
92
+ .map((part) => {
93
+ const block = part;
94
+ return block.type === "text" ? (block.text ?? "") : "";
95
+ })
96
+ .filter((text) => text !== "")
97
+ .join("\n");
98
+ }
99
+ /** Stop the server. A stopped server is started again on the next call. */
100
+ async close() {
101
+ if (this.closed)
102
+ return;
103
+ this.closed = true;
104
+ this.lines.close();
105
+ this.child.kill("SIGTERM");
106
+ await new Promise((resolve) => {
107
+ if (this.child.exitCode !== null || this.child.signalCode !== null) {
108
+ resolve();
109
+ return;
110
+ }
111
+ this.child.once("exit", () => resolve());
112
+ setTimeout(() => {
113
+ this.child.kill("SIGKILL");
114
+ resolve();
115
+ }, 2000).unref();
116
+ });
117
+ }
118
+ functionOf(tool) {
119
+ const spec = tool;
120
+ const name = spec.name ?? "";
121
+ const properties = spec.inputSchema?.properties ?? {};
122
+ const required = new Set(Array.isArray(spec.inputSchema?.required) ? spec.inputSchema?.required : []);
123
+ const parameters = Object.keys(properties).map((key) => `${key}${required.has(key) ? "" : "?"}`);
124
+ return {
125
+ name: `${this.options.name}.${name}`,
126
+ signature: `${this.options.name}.${name}(${parameters.join(", ")})`,
127
+ description: spec.description ?? "",
128
+ };
129
+ }
130
+ notify(method, params) {
131
+ this.send({ jsonrpc: "2.0", method, params });
132
+ }
133
+ async request(method, params) {
134
+ const id = this.nextId;
135
+ this.nextId += 1;
136
+ const answer = new Promise((resolve, reject) => {
137
+ this.waiting.set(id, { resolve, reject });
138
+ const timer = setTimeout(() => {
139
+ this.waiting.delete(id);
140
+ reject(arborError({
141
+ code: "unavailable",
142
+ rule: "mcp.server.timeout",
143
+ message: `MCP 服务器 ${this.options.name} 没有在 ${this.timeoutMs} ms 内回答 ${method}`,
144
+ nextAction: "检查服务器是否卡住;修好后用 reload 重新安装它。",
145
+ details: { server: this.options.name, method },
146
+ }));
147
+ }, this.timeoutMs);
148
+ timer.unref();
149
+ });
150
+ this.send({ jsonrpc: "2.0", id, method, params });
151
+ return answer;
152
+ }
153
+ send(message) {
154
+ if (this.closed || this.child.stdin?.destroyed === true) {
155
+ throw arborError({
156
+ code: "unavailable",
157
+ rule: "mcp.server.closed",
158
+ message: `MCP 服务器 ${this.options.name} 已停止`,
159
+ nextAction: "用 reload 重新安装该服务器。",
160
+ details: { server: this.options.name },
161
+ });
162
+ }
163
+ this.child.stdin?.write(`${JSON.stringify(message)}\n`);
164
+ }
165
+ answer(line) {
166
+ if (line.trim() === "")
167
+ return;
168
+ let message;
169
+ try {
170
+ message = JSON.parse(line);
171
+ }
172
+ catch {
173
+ return;
174
+ }
175
+ const pending = message.id === undefined ? undefined : this.waiting.get(message.id);
176
+ if (pending === undefined)
177
+ return;
178
+ this.waiting.delete(message.id);
179
+ if (message.error !== undefined) {
180
+ pending.reject(arborError({
181
+ code: "refused",
182
+ rule: "mcp.server.error",
183
+ message: `MCP 服务器 ${this.options.name}:${message.error.message ?? "未知错误"}`,
184
+ nextAction: "按错误修正服务器或参数后重试。",
185
+ details: { server: this.options.name },
186
+ }));
187
+ return;
188
+ }
189
+ pending.resolve(message.result);
190
+ }
191
+ /** A server that died leaves no request hanging. */
192
+ failAll(error) {
193
+ if (this.closed)
194
+ return;
195
+ this.closed = true;
196
+ for (const [, pending] of this.waiting) {
197
+ pending.reject(arborError({
198
+ code: "unavailable",
199
+ rule: "mcp.server.died",
200
+ message: `MCP 服务器 ${this.options.name} 已退出:${error.message}`,
201
+ nextAction: "查看服务器输出,修好后用 reload 重新安装。",
202
+ details: { server: this.options.name, stderr: this.stderr.join("") },
203
+ }));
204
+ }
205
+ this.waiting.clear();
206
+ }
207
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@4fu/arbor-environment-dir",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "The default Environment: skills, MCP servers, services and settings in one shared /env directory, with a revision per item, validation and deltas.",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./index.d.ts",
9
+ "default": "./index.js"
10
+ }
11
+ },
12
+ "types": "./index.d.ts",
13
+ "main": "./index.js",
14
+ "files": [
15
+ "directory-environment.d.ts",
16
+ "directory-environment.js",
17
+ "index.d.ts",
18
+ "index.js",
19
+ "mcp.d.ts",
20
+ "mcp.js",
21
+ "skills/arbor-mcp/SKILL.md",
22
+ "skills/arbor-service/SKILL.md",
23
+ "skills/arbor-settings/SKILL.md",
24
+ "skills/arbor-skill/SKILL.md"
25
+ ],
26
+ "engines": {
27
+ "node": ">=24"
28
+ },
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/4fuu/arbor.git",
33
+ "directory": "packages/environment-dir"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "@4fu/arbor-errors": "0.1.0",
40
+ "@4fu/arbor-interfaces": "0.1.0"
41
+ }
42
+ }
@@ -0,0 +1,51 @@
1
+ # 接一个 MCP 服务器
2
+
3
+ MCP 服务器是一种能力:装上之后,它的函数在 `code_exec` 里像一个模块一样调用。插件
4
+ 不增加直接工具,能力都是 MCP 服务器。
5
+
6
+ ## 结构
7
+
8
+ 一个 MCP 服务器的配置是一个 JSON 文件,草稿可以是一个文件,也可以是一个带着代码的
9
+ 目录:
10
+
11
+ ```json
12
+ {
13
+ "command": "node",
14
+ "args": ["server.js"],
15
+ "secrets": ["MY_TOKEN"]
16
+ }
17
+ ```
18
+
19
+ - `command` 和 `args`:服务器怎么启动。用目录作草稿时,命令在这个目录里运行,所以
20
+ `server.js` 这样的相对路径可以。
21
+ - `url`:一个已经跑起来的 HTTP 服务器,与 command 二选一。
22
+ - `secrets`:它用到的秘密的名字,只写名字,不写值。值由主机按名字注入。
23
+
24
+ 安装后是 `/env/mcp/<name>.json`(文件草稿)或 `/env/mcp/<name>/<name>.json`(目录草稿)。
25
+
26
+ ## 自测
27
+
28
+ 1. 在本窗口工作区里写好服务器和配置。
29
+ 2. 先把它跑起来:`bash node server.js`,看它有没有在等输入、有没有报错。
30
+ 3. 用 `tool_search("<name>")` 查它的函数,用 `tool_describe("<name>.<fn>")` 看签名。
31
+ 4. 在 `code_exec` 里调用一次,确认返回的是文本。
32
+
33
+ ## 安装
34
+
35
+ `reload({ install: ["./<name>.json"] })`,或目录草稿 `reload({ install: ["./<name>"] })`。
36
+
37
+ reload 会真的把服务器启动起来、列出它的函数,通过之后才安装。启动失败或列不出函数时
38
+ 调用被拒绝,什么都不改变。
39
+
40
+ ## 修订冲突
41
+
42
+ 草稿是基于某个修订复制的。如果这期间别的窗口装了更新的修订,这次调用会被拒绝,拒绝
43
+ 里列出中间的修订。
44
+
45
+ 按此处理:重新复制最新版,把两边的内容合并进草稿,再调用一次 reload。
46
+
47
+ ## 回退
48
+
49
+ `reload({ remove: ["mcp:<name>"] })` 移除它,它的函数随之不可调用;修订历史用
50
+ `env.history("mcp:<name>")` 查看。移除不改变任何窗口已经拿到的系统提示,对已移除函
51
+ 数的调用会被拒绝,并带上移除它的那次变更。
@@ -0,0 +1,55 @@
1
+ # 接一个常驻服务
2
+
3
+ 服务是一个跑完本窗口还不结束的应用:webhook 接收、爬虫、机器人、worker。`bash` 的后
4
+ 台作业随窗口链结束,所以常驻的必须是服务。
5
+
6
+ 框架负责:崩溃后按退避重启、主机重启后重新起来、它自己的目录、日志、按名字注入秘密。
7
+ 你负责:写应用、装依赖、写 `svc.json`、决定什么时候停掉或移除。
8
+
9
+ ## 结构
10
+
11
+ 服务是一个目录,里面有 `svc.json` 和应用文件:
12
+
13
+ ```json
14
+ {
15
+ "command": "node",
16
+ "args": ["bot.js"],
17
+ "ports": [8080],
18
+ "healthCheck": { "path": "/healthz", "port": 8080, "intervalMs": 500, "timeoutMs": 10000 },
19
+ "secrets": ["BOT_TOKEN"],
20
+ "restart": { "maxRetries": 5, "backoffMs": 2000 }
21
+ }
22
+ ```
23
+
24
+ - `command`、`args`:怎么启动,在这个目录里运行。
25
+ - `ports`:它监听的端口,窗口和别的服务可以从主机访问。
26
+ - `healthCheck`:reload 等它通过才安装。按 `intervalMs` 轮询
27
+ `http://127.0.0.1:<port><path>`,`timeoutMs` 内没有 2xx 就算失败。
28
+ - `secrets`:秘密的名字,只写名字;值由主机按名字注入。
29
+ - `restart`:崩溃后最多重试几次,每次之间等多久。
30
+
31
+ 安装后是 `/env/services/<name>/`,目录归服务所有,没有窗口能结束它或回收它。
32
+
33
+ ## 自测
34
+
35
+ 1. 在本窗口工作区里写好应用和 `svc.json`。
36
+ 2. 先直接跑:`bash node bot.js`,确认它起来、监听端口、`/healthz` 有响应。
37
+ 3. 用 `curl` 或 `bash` 打一次健康检查路径,确认返回 2xx。
38
+ 4. 故意让它退出一次,看重启和日志是否符合预期。
39
+
40
+ ## 安装
41
+
42
+ `reload({ install: ["./<name>"] })`。
43
+
44
+ reload 把草稿目录复制一份归服务所有,启动它,等健康检查通过,然后才安装。健康检查不
45
+ 通过时调用被拒绝,什么都不改变,日志留在服务日志里。
46
+
47
+ ## 失败是输入
48
+
49
+ 崩溃、重启成环、健康检查不过,都会成为一条目标为 `svc:<name>` 的记录事件,和任何外部
50
+ 输入一样被派发。主机不替你修。
51
+
52
+ ## 回退
53
+
54
+ 升级是再一次 `reload({ install: ["./<name>"] })`;回退是安装旧的那一版,修订历史用
55
+ `env.history("svc:<name>")` 查看。`reload({ remove: ["service:<name>"] })` 停掉并移除它。
@@ -0,0 +1,52 @@
1
+ # 改设置
2
+
3
+ 设置是 `/env/settings.json`,和环境里其他项一样是一个有修订的项。模型可以改关于自己的
4
+ 一切:身份、通用规则、judge 模式、阈值、模型分层。两样不能碰:秘密的值,以及框架内置
5
+ 的技能。
6
+
7
+ ## 结构
8
+
9
+ ```json
10
+ {
11
+ "identity": "你是部署在内部集群上的运维 agent。",
12
+ "generalRules": ["窗口是一次性的。", "发送不是必须的。"],
13
+ "modelTiers": { "fast": "claude-sonnet-4-5", "deep": "claude-opus-4-5" },
14
+ "defaultTier": "fast",
15
+ "window": { "idleSeconds": 300, "contextTokens": 200000, "reserveTokens": 4000 },
16
+ "snapshot": { "minIntervalMs": 600000 },
17
+ "workspaces": { "retentionMs": 604800000 },
18
+ "judge": { "modes": { "route": "on", "notice": "shadow" }, "maxCandidates": 8 },
19
+ "secrets": ["BOT_TOKEN"]
20
+ }
21
+ ```
22
+
23
+ - `identity`:`<identity>` 一节,为空时不渲染,没有默认句。
24
+ - `generalRules`:`<agent>` 一节的通用规则,一行一条。
25
+ - `modelTiers`、`defaultTier`:每一层映射到后端能跑的一个模型。
26
+ - `window`:空闲秒数、上下文预算、预留 token。
27
+ - `snapshot.minIntervalMs`:系统提示和记忆快照最多多久重建一次。
28
+ - `workspaces.retentionMs`:窗口链最后一个窗口结束后,它的工作区目录再保留多久由主机回收;不设就不回收。
29
+ - `judge.modes`:每个决策是 `on`、`off` 还是 `shadow`。
30
+ - `secrets`:允许使用的秘密的名字,只写名字,不写值。
31
+
32
+ ## 自测
33
+
34
+ 1. `read /env/settings.json` 看当前值。
35
+ 2. 在本窗口工作区里改:`cp /env/settings.json .`,再 `edit`。
36
+ 3. 用 `env.list` 确认改动落在哪一项上,用 `env.history("set:settings")` 看改过什么。
37
+
38
+ ## 安装
39
+
40
+ `reload({ install: ["./settings.json"] })`。
41
+
42
+ 身份和通用规则不是变更增量:它们属于系统提示,从下一个快照 epoch 起生效。运行中的窗口
43
+ 拿到的还是一次 epoch 冻结的那一版。
44
+
45
+ ## 修订冲突
46
+
47
+ 草稿是基于某个修订复制的。如果这期间别的窗口改过设置,这次调用会被拒绝,拒绝里列出中
48
+ 间的修订。重新复制最新版,合并后再调用一次。
49
+
50
+ ## 回退
51
+
52
+ 装回旧的那一版就是重新安装它;修订历史用 `env.history("set:settings")` 查看。
@@ -0,0 +1,43 @@
1
+ # 写一个技能
2
+
3
+ 技能是一份过程说明:模型按需读取它,按它的步骤做。一个技能是一个目录,里面有
4
+ SKILL.md,以及它用到的脚本和文件。
5
+
6
+ ## 结构
7
+
8
+ 安装后的位置是 `/env/skills/<name>/`,name 用小写字母、数字和连字符。
9
+
10
+ SKILL.md 的第一行是一级标题,第二段是这一行说明:它做什么、什么时候用。系统提示
11
+ 和每次环境变更只用这一行,所以写清楚做什么,不写怎么做。
12
+
13
+ 正文按步骤写:每步一个动作,带上要调用的工具或命令。不要写背景说明,不要重复框架
14
+ 的规则,那些在系统提示里。
15
+
16
+ 技能自带的脚本用 `bash` 从 `/env/skills/<name>/` 运行,用的是本窗口自己的权限。
17
+
18
+ ## 自测
19
+
20
+ 1. 在本窗口工作区里写好:`write SKILL.md` 和脚本。
21
+ 2. 读一遍自己写的:`read SKILL.md`。
22
+ 3. 跑一遍脚本:`bash ./run.sh`,确认它在这个目录里能跑完。
23
+ 4. 看第二段是不是一句话能说完的说明;不是就改短。
24
+
25
+ ## 安装
26
+
27
+ `reload({ install: ["./<name>"] })`。
28
+
29
+ install 指向工作区里的草稿目录。reload 校验结构、整体替换、记录一个环境修订,并把
30
+ 变更广播给所有运行中的窗口。
31
+
32
+ ## 修订冲突
33
+
34
+ 草稿是基于某个修订复制的。如果这期间别的窗口装了更新的修订,这次调用会被拒绝,拒绝
35
+ 里列出中间的修订。
36
+
37
+ 按此处理:`cp -r /env/skills/<name> .` 重新复制最新版,把两边的内容合并进草稿,再
38
+ 调用一次 reload。
39
+
40
+ ## 回退
41
+
42
+ `reload({ remove: ["skill:<name>"] })` 移除它。装回旧版就是重新安装那一版;修订历史用
43
+ `env.history("sk:<name>")` 查看。