@4fu/dsh-arbor-env 0.0.0-stage → 2026.1110.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/LICENSE +21 -0
- package/README.md +234 -2
- package/index.d.ts +121 -0
- package/index.js +248 -0
- package/package.json +44 -4
- package/servers.d.ts +37 -0
- package/servers.js +52 -0
- package/skills.d.ts +69 -0
- package/skills.js +88 -0
- package/text.d.ts +14 -0
- package/text.js +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 4fuu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,235 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-arbor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run [arbor](https://github.com/4fuu/arbor) as the agent driver inside
|
|
4
|
+
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH).
|
|
5
|
+
|
|
6
|
+
DSH gives a user a session, a transcript, approvals, plugins, skills and a model
|
|
7
|
+
picker. arbor gives an agent disposable context windows over one shared journal,
|
|
8
|
+
note store and workspace, instead of one long conversation. dsh-arbor connects
|
|
9
|
+
them: one arbor host runs inside the DSH process, each DSH session that selects
|
|
10
|
+
**Arbor** is served by that host, and the window's reply is written back to the
|
|
11
|
+
DSH session as an ordinary assistant message. A DSH session that does not select
|
|
12
|
+
Arbor keeps running on DSH's own embedded AgentLoop, in the same process.
|
|
13
|
+
|
|
14
|
+
> **Status.** This is a research-grade integration, not a DSH release. It is
|
|
15
|
+
> pinned to one DSH version and relies on DSH internals. Read
|
|
16
|
+
> [Versions and compatibility](docs/versions.md) before installing it on
|
|
17
|
+
> anything you care about. The packages are published under `@4fu` at
|
|
18
|
+
> `2026.1110.0`; the bundle and the font plugin install independently from npm.
|
|
19
|
+
|
|
20
|
+
## What an arbor session changes
|
|
21
|
+
|
|
22
|
+
Choosing **Arbor** in a DSH composer creates the session under the `arbor` agent
|
|
23
|
+
preset. That session then runs on arbor, and the following are true for it:
|
|
24
|
+
|
|
25
|
+
- **One window per turn, one chain per session.** A DSH session is one arbor
|
|
26
|
+
conversation: its first turn opens a window, a window that ends hands off to a
|
|
27
|
+
continuation, and the next turn drives the chain. The journal and note store
|
|
28
|
+
live under `$DSH_HOME/arbor/` and survive a restart.
|
|
29
|
+
- **arbor's workspace, not DSH's sandbox.** A window works in arbor's shared
|
|
30
|
+
folder (`$DSH_HOME/arbor/shared`). When the `ovnfs` binary is available a
|
|
31
|
+
window opens a copy-on-write checkout and merges back with `apply`; otherwise
|
|
32
|
+
arbor uses a plain directory. **There is no DSH sandbox for an arbor session:**
|
|
33
|
+
arbor's file tools and commands run on the host, and arbor's own secret scrub
|
|
34
|
+
is the only protection on what reaches the model.
|
|
35
|
+
- **arbor's projects.** The shared folder's project folders are the DSH project
|
|
36
|
+
list for arbor composers. A directory the user adds outside the shared folder
|
|
37
|
+
is recorded as an external pointer for that session and used in place.
|
|
38
|
+
- **arbor's prompt and tools.** DSH's official prompt sections are decided one by
|
|
39
|
+
one; the ones arbor replaces are absent, third-party sections go into a
|
|
40
|
+
`<dsh>` data block, and a trusted `<harness>` section says what remains true of
|
|
41
|
+
DSH. DSH tools are offered to the model as arbor external tools, and arbor's
|
|
42
|
+
own tools replace DSH tools with the same capability.
|
|
43
|
+
- **The Arbor page.** A permanent **Arbor** entry in DSH's left navigation opens
|
|
44
|
+
the whole-host overview and one tab per arbor session: windows, chains,
|
|
45
|
+
handoffs, projects, the board, notes, `/env`, schedules and arbor's search.
|
|
46
|
+
An arbor composer hides DSH's sandbox access-mode switch, because that switch
|
|
47
|
+
does not apply to it.
|
|
48
|
+
|
|
49
|
+
## What stays untouched in ordinary DSH sessions
|
|
50
|
+
|
|
51
|
+
A session that does not select Arbor is an ordinary DSH session. It keeps:
|
|
52
|
+
|
|
53
|
+
- DSH's embedded AgentLoop, session format, turn boundaries and streaming path;
|
|
54
|
+
- DSH's sandbox policy and its `ctx.fs` / `ctx.shell` file and command tools;
|
|
55
|
+
- DSH's project management, working directory and prompt assembly;
|
|
56
|
+
- its composer, sandbox access-mode switch, header and sidebar behavior.
|
|
57
|
+
|
|
58
|
+
It gets **no Arbor tab** and no arbor windows. The only shared surface is the
|
|
59
|
+
permanent Arbor entry in the left navigation, which simply does not list that
|
|
60
|
+
session. The routing registry replaces DSH's base `agent` row so it can send
|
|
61
|
+
`agentPreset: 'arbor'` sessions to arbor; every other session goes to the
|
|
62
|
+
official loop. Approvals still use DSH's approval path for both kinds of
|
|
63
|
+
session, and arbor's windows are registered so DSH's sidebar leaves them out.
|
|
64
|
+
|
|
65
|
+
## Screenshots
|
|
66
|
+
|
|
67
|
+
| Overview, light | Overview, dark |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
|  |  |
|
|
70
|
+
| **Session tab, light** | **Session tab, dark** |
|
|
71
|
+
|  |  |
|
|
72
|
+
|
|
73
|
+
These are the web profile at 1440 px wide with the optional
|
|
74
|
+
[font plugin](https://github.com/4fuu/dsh-arbor-fonts) installed: ChillDIN
|
|
75
|
+
Gothic for labels and ChillDIN for numerals, while body text and code keep
|
|
76
|
+
DSH's fonts. The same overview without the plugin is
|
|
77
|
+
[here](docs/screenshots/overview-without-fonts.png).
|
|
78
|
+
|
|
79
|
+
## Install
|
|
80
|
+
|
|
81
|
+
Requirements: Node.js 24.9 or newer and DeepSeek Harness `dsh-v0.2.2-rc.1`.
|
|
82
|
+
The bundle is published with all packages it depends on; nothing has to be
|
|
83
|
+
built or linked.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm install -g @deepseek-ai/dsh@0.2.2-rc.1
|
|
87
|
+
dsh plugin --profile web add @4fu/dsh-arbor-bundle@2026.1110.0
|
|
88
|
+
dsh web
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`dsh plugin` adds the bundle to the profile's `dsh.profile.bundles` and installs
|
|
92
|
+
its packages into the profile. Pick **Arbor** in a composer and send a message.
|
|
93
|
+
The optional [font plugin](https://github.com/4fuu/dsh-arbor-fonts) installs the
|
|
94
|
+
same way:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
dsh plugin --profile web add @4fu/dsh-arbor-fonts@2026.1110.0
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### From a checkout
|
|
101
|
+
|
|
102
|
+
The proofs and development use the TypeScript sources directly; that checkout is
|
|
103
|
+
what the scripts and the pin check run against.
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
git clone https://github.com/4fuu/dsh-arbor
|
|
107
|
+
cd dsh-arbor
|
|
108
|
+
pnpm install --frozen-lockfile
|
|
109
|
+
pnpm check # tsc --noEmit over every package and proof
|
|
110
|
+
pnpm proofs # 29 proofs against the scripted model
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The first install may ask pnpm to approve dependency build scripts. Do not
|
|
114
|
+
approve them by hand: the repository's `pnpm-workspace.yaml` already denies the
|
|
115
|
+
six scripts it pulls in, each with the reason, and `pnpm install` finishes
|
|
116
|
+
cleanly with that policy.
|
|
117
|
+
|
|
118
|
+
### Hand the install to a model
|
|
119
|
+
|
|
120
|
+
[docs/install-for-agents.md](docs/install-for-agents.md) is a step-by-step guide
|
|
121
|
+
written for an AI agent: install from npm or a checkout, run the proofs, drive a
|
|
122
|
+
live turn with a restart, boot the web profile, and inspect what ran. Give a
|
|
123
|
+
model this prompt, editing the bracketed parts or dropping them:
|
|
124
|
+
|
|
125
|
+
```text copy Copy Install Prompt
|
|
126
|
+
Install dsh-arbor (https://github.com/4fuu/dsh-arbor) in this environment [in a
|
|
127
|
+
scratch DSH home under /tmp] and show me that it works: follow
|
|
128
|
+
docs/install-for-agents.md, install @4fu/dsh-arbor-bundle@2026.1110.0 into a DSH
|
|
129
|
+
web profile, pick Arbor for one session, and [also run the proof suite from a
|
|
130
|
+
checkout with pnpm install --frozen-lockfile, pnpm check and pnpm proofs]. Report
|
|
131
|
+
which checks passed, what the live session answered, and anything that did not
|
|
132
|
+
work. Fix only what is yours to fix and tell me what you changed.
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Use it in DSH
|
|
136
|
+
|
|
137
|
+
The npm install above adds the bundle to a DSH web profile. Pick **Arbor** in a
|
|
138
|
+
composer and send a message: the session is created under the preset, its window
|
|
139
|
+
and chain appear under the **Arbor** entry, and an ordinary session started
|
|
140
|
+
beside it keeps DSH's own composer and sandbox behavior. A checkout can also be
|
|
141
|
+
wired by hand; [section 7 of the install guide](docs/install-for-agents.md#7-wiring-the-bundle-into-a-real-dsh-installation)
|
|
142
|
+
shows the profile layout.
|
|
143
|
+
|
|
144
|
+
The one-shot `dsh <task>` runner cannot drive an arbor-preset session: DSH's
|
|
145
|
+
runner refuses to adopt a session that runs under a preset. Use the web client,
|
|
146
|
+
the SDK path the proofs use, or a remote `agent-preset/select` on a blank.
|
|
147
|
+
|
|
148
|
+
## Pinned DSH version
|
|
149
|
+
|
|
150
|
+
DSH is pinned to **`dsh-v0.2.2-rc.1`** (npm
|
|
151
|
+
`@deepseek-ai/dsh@0.2.2-rc.1`, commit `66491b05e3` of
|
|
152
|
+
`deepseek-ai/deepseek-harness`). Every `@deepseek-ai/dsh*` dependency is an
|
|
153
|
+
exact version, every package under `packages/` declares
|
|
154
|
+
`engines.dsh: 0.2.2-rc.1`, and `pnpm check:pin` checks both plus the prompt
|
|
155
|
+
bridge's `DSH_PINNED_VERSION`.
|
|
156
|
+
|
|
157
|
+
A different DSH version is unsupported. The bundle depends on DSH's row names,
|
|
158
|
+
its one-`AgentFactory` rule, its prompt-section registry, its client slot
|
|
159
|
+
registration and its exact Fetch registry; a move in any of them can fail the
|
|
160
|
+
boot, leave an official prompt section undecided, or stop the Arbor page from
|
|
161
|
+
registering. `proofs/12-official-sections.ts` and `proofs/18-picker-pin.ts`
|
|
162
|
+
are the behavioral checks. The canonical pin and the upgrade procedure are in
|
|
163
|
+
[Versions and compatibility](docs/versions.md).
|
|
164
|
+
|
|
165
|
+
## Optional: the font plugin
|
|
166
|
+
|
|
167
|
+
[dsh-arbor-fonts](https://github.com/4fuu/dsh-arbor-fonts) is a separate DSH
|
|
168
|
+
client plugin that changes only the typography of the Arbor page. It sets
|
|
169
|
+
ChillDIN Gothic for display labels and ChillDIN for numerals, leaves body text
|
|
170
|
+
and code on DSH's fonts, and does nothing on ordinary pages. It is optional and
|
|
171
|
+
installs into the same DSH profile:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
dsh plugin --profile web add @4fu/dsh-arbor-fonts@2026.1110.0
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The fonts are licensed under the SIL Open Font License 1.1; the plugin ships the
|
|
178
|
+
license text, per-file hashes and attribution.
|
|
179
|
+
|
|
180
|
+
## Limits worth knowing
|
|
181
|
+
|
|
182
|
+
- The judge is off: a model-based judge cannot run through the DSH backend yet.
|
|
183
|
+
- The 12 bundle packages are published together at one version; install and
|
|
184
|
+
upgrade the bundle as a unit, because its packages name each other exactly.
|
|
185
|
+
- `ovnfs` is optional. Without it arbor uses the plain-directory workspace, and a
|
|
186
|
+
window no longer gets a copy-on-write checkout.
|
|
187
|
+
- The web page's numbers and layout are read from arbor's journal and API; it is
|
|
188
|
+
not a generic DSH dashboard, and it deliberately shows only arbor sessions.
|
|
189
|
+
- The prompt bridge, the UI and the picker filter are pinned to DSH's internals,
|
|
190
|
+
not to a stable public API. See [Versions and compatibility](docs/versions.md).
|
|
191
|
+
|
|
192
|
+
## Documentation
|
|
193
|
+
|
|
194
|
+
- [Installing and inspecting dsh-arbor](docs/install-for-agents.md) — the
|
|
195
|
+
agent-facing install, proof, live-turn, web-profile and wiring guide.
|
|
196
|
+
- [Versions and compatibility](docs/versions.md) — the DSH and arbor pins, and
|
|
197
|
+
what breaks on another DSH version.
|
|
198
|
+
- [B0 findings](docs/b0-findings.md), [B3 findings](docs/b3-findings.md) and
|
|
199
|
+
[B3b projects and sandbox](docs/b3b-projects-and-sandbox.md) — historical
|
|
200
|
+
records of the integration's decisions, with the DSH source locations each
|
|
201
|
+
one rests on. They describe earlier stages; the install guide is the current
|
|
202
|
+
status.
|
|
203
|
+
- [Layout and proofs](#layout) below for a quick map of the packages.
|
|
204
|
+
|
|
205
|
+
## Layout
|
|
206
|
+
|
|
207
|
+
```text
|
|
208
|
+
packages/
|
|
209
|
+
arbor-host/ one Arbor host per process: SQLite journal and notes,
|
|
210
|
+
settings, window lifecycle, workspace and environment
|
|
211
|
+
arbor-driver/ the routing AgentRegistry, AgentFactory, session agent,
|
|
212
|
+
inbox and stream mirrors, and the journal relay
|
|
213
|
+
arbor-llm/ the arbor AgentBackend over DSH's ctx.llm and ctx.tools
|
|
214
|
+
arbor-prompt/ <harness>, the <dsh> data block, and the section table
|
|
215
|
+
arbor-tools/ DSH tools as arbor external tools, same-name replacement
|
|
216
|
+
arbor-workspace/ arbor's workspace over the shared folder (ovnfs or dir)
|
|
217
|
+
arbor-env/ /env, with DSH's skills and MCP servers mounted read-only
|
|
218
|
+
arbor-bundle/ the installable patch that mounts the rows and UI
|
|
219
|
+
arbor-facts/ the read face behind the Arbor page and its Fetch routes
|
|
220
|
+
arbor-ui/ the Arbor page, session tabs, copy and composer gate
|
|
221
|
+
arbor-ui-workspace/
|
|
222
|
+
the per-preset hero project picker
|
|
223
|
+
arbor-preset/ the agent-preset row that puts Arbor in the roster
|
|
224
|
+
arbor-testkit/ the boot harness, memory journal and scripted model
|
|
225
|
+
patches/ overlay layers a proof applies above the shipped profile
|
|
226
|
+
proofs/ one runnable proof per integration point
|
|
227
|
+
docs/ install, versions, screenshots and historical findings
|
|
228
|
+
scripts/ the DSH pin check, the release build and the publish dry run
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## License
|
|
232
|
+
|
|
233
|
+
MIT. See [LICENSE](LICENSE). The optional font plugin vendors fonts under the
|
|
234
|
+
SIL Open Font License 1.1; its repository carries the license text and
|
|
235
|
+
attribution.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/env` inside DSH: arbor's own environment, with DSH's skills and MCP servers
|
|
3
|
+
* mounted read-only beside it (docs/environment.md).
|
|
4
|
+
*
|
|
5
|
+
* The environment's home is `<DSH_HOME>/arbor/env`, and it is arbor's own
|
|
6
|
+
* `DirectoryEnvironment`: the settings at `/env/settings.json`, arbor's built-in
|
|
7
|
+
* skills, and everything the model installs itself with `reload` all keep
|
|
8
|
+
* working exactly as they do in any other deployment.
|
|
9
|
+
*
|
|
10
|
+
* What this package adds is DSH's side of the world:
|
|
11
|
+
*
|
|
12
|
+
* - **DSH's skills** are read from `ctx.skills`, DSH's own layered registry,
|
|
13
|
+
* and mounted by writing each one's body to `<env>/skills/<name>/SKILL.md`.
|
|
14
|
+
* The model sees them in `<env>` with that path and reads them with `read`;
|
|
15
|
+
* they are read-only, because a change goes through DSH, not through arbor's
|
|
16
|
+
* `reload`.
|
|
17
|
+
* - **DSH's MCP servers** are listed from the tools DSH publishes under the
|
|
18
|
+
* `mcp__<server>__<tool>` names. Their tools stay DSH tools — the tool bridge
|
|
19
|
+
* brings them in as external tools — so the item declares no functions and no
|
|
20
|
+
* arbor MCP module is created for it. Duplicating them as modules would give
|
|
21
|
+
* the model two ways to call the same tool.
|
|
22
|
+
*
|
|
23
|
+
* The mount is live: `skills/change` re-reads DSH's catalog, so a skill a DSH
|
|
24
|
+
* plugin adds or removes reaches `/env` without a restart.
|
|
25
|
+
*/
|
|
26
|
+
import type { EnvDelta, EnvDraft, EnvItem, EnvItemName, EnvItemRef, EnvRef, EnvRevision, EnvValidation, Environment, Settings } from '@4fu/arbor-interfaces';
|
|
27
|
+
import { DirectoryEnvironment } from '@4fu/arbor-environment-dir';
|
|
28
|
+
import { type DshSkillSource } from './skills.js';
|
|
29
|
+
/** What this package needs from the DSH process it runs in. */
|
|
30
|
+
export interface DshEnvironmentOptions {
|
|
31
|
+
/** The environment arbor's own items live in, rooted under `DSH_HOME/arbor`. */
|
|
32
|
+
readonly inner: DirectoryEnvironment;
|
|
33
|
+
/** DSH's own skill registry, and how to hear that it moved. */
|
|
34
|
+
readonly skills: DshSkillSource;
|
|
35
|
+
/** Every tool name DSH publishes, for the MCP servers among them. */
|
|
36
|
+
readonly toolNames: () => readonly string[];
|
|
37
|
+
/** The working directory DSH's skills are read for. Defaults to the host's. */
|
|
38
|
+
readonly cwd?: () => string;
|
|
39
|
+
/**
|
|
40
|
+
* The settings the host runs with until the model installs its own at
|
|
41
|
+
* `/env/settings.json`.
|
|
42
|
+
*
|
|
43
|
+
* Arbor reads its identity, general rules, judge and model tiers from the
|
|
44
|
+
* environment once one is mounted, and answers `undefined` until a settings
|
|
45
|
+
* item is installed — which would leave a window with no identity at all. The
|
|
46
|
+
* deployment's own settings are the seed, so a fresh `DSH_HOME` behaves
|
|
47
|
+
* exactly as it did before `/env` existed, and the model's own install
|
|
48
|
+
* replaces them the moment it makes one.
|
|
49
|
+
*/
|
|
50
|
+
readonly seedSettings?: Settings;
|
|
51
|
+
}
|
|
52
|
+
export declare class DshEnvironment implements Environment {
|
|
53
|
+
private readonly inner;
|
|
54
|
+
private readonly skills;
|
|
55
|
+
private readonly toolNames;
|
|
56
|
+
private readonly cwd;
|
|
57
|
+
private readonly seed;
|
|
58
|
+
/** The mounted DSH skills and MCP servers, read the last time `list` ran. */
|
|
59
|
+
private mounted;
|
|
60
|
+
/** The names DSH owns, which arbor's own `reload` may not touch. */
|
|
61
|
+
private owned;
|
|
62
|
+
/** Set while this package is writing the mount, so a re-read is not recursive. */
|
|
63
|
+
private syncing;
|
|
64
|
+
constructor(options: DshEnvironmentOptions);
|
|
65
|
+
/** The shared mount every window reads `/env` through. */
|
|
66
|
+
get mount(): string;
|
|
67
|
+
/** The directory the mount is, on the host, which the file tools read. */
|
|
68
|
+
get directory(): string;
|
|
69
|
+
/** Every item: arbor's own, then DSH's mounted ones. */
|
|
70
|
+
list(): Promise<readonly EnvItem[]>;
|
|
71
|
+
/** Re-read DSH's skills and MCP servers, and write the mount. */
|
|
72
|
+
refresh(): Promise<void>;
|
|
73
|
+
/** The items DSH owns, as arbor's environment lists them. */
|
|
74
|
+
private dshItems;
|
|
75
|
+
/** The revisions, arbor's own: a mounted item has none of its own to give. */
|
|
76
|
+
history(item?: EnvItemRef | EnvItemName): Promise<readonly EnvRevision[]>;
|
|
77
|
+
/**
|
|
78
|
+
* The settings: the model's own from `/env/settings.json` once it installs
|
|
79
|
+
* one, and the deployment's seed until then.
|
|
80
|
+
*/
|
|
81
|
+
settings(): Promise<Settings | undefined>;
|
|
82
|
+
/**
|
|
83
|
+
* Validate drafts, refusing the whole call when one names an item DSH owns.
|
|
84
|
+
*
|
|
85
|
+
* Everything else is the inner environment's own validation, so a skill
|
|
86
|
+
* parses and an MCP server starts and lists its functions exactly as it does
|
|
87
|
+
* in any other deployment.
|
|
88
|
+
*/
|
|
89
|
+
validate(drafts: readonly EnvDraft[]): Promise<EnvValidation>;
|
|
90
|
+
/** Install drafts, refusing one that names an item DSH owns. */
|
|
91
|
+
install(drafts: readonly EnvDraft[], remove?: readonly (EnvItemRef | EnvItemName)[]): Promise<EnvRevision>;
|
|
92
|
+
/** Remove items, refusing one that names an item DSH owns. */
|
|
93
|
+
remove(items: readonly (EnvItemRef | EnvItemName)[]): Promise<EnvRevision>;
|
|
94
|
+
/** What changed since a revision. DSH's mounted items never change one. */
|
|
95
|
+
delta(since: EnvRef): Promise<EnvDelta>;
|
|
96
|
+
/** The revision the environment stands at, arbor's own. */
|
|
97
|
+
revision(): Promise<EnvRef>;
|
|
98
|
+
/** Start every installed service again, after a host restart. */
|
|
99
|
+
resume(): Promise<void>;
|
|
100
|
+
/**
|
|
101
|
+
* Call one function of an installed MCP server.
|
|
102
|
+
*
|
|
103
|
+
* A mounted DSH server has no functions here — its tools are DSH tools and
|
|
104
|
+
* run through the tool bridge — so a call naming one is refused rather than
|
|
105
|
+
* silently sent somewhere else.
|
|
106
|
+
*/
|
|
107
|
+
call(server: string, fn: string, args: Record<string, unknown>): Promise<string>;
|
|
108
|
+
/** One refusal naming an item DSH owns, in this package's own words. */
|
|
109
|
+
private readonly;
|
|
110
|
+
/** One fixed string, from this package's own table. */
|
|
111
|
+
private text;
|
|
112
|
+
/** The drafts whose names DSH owns, as `kind:name` entries. */
|
|
113
|
+
private takenBy;
|
|
114
|
+
/** The item names a removal list names, as `kind:name` entries. */
|
|
115
|
+
private namedBy;
|
|
116
|
+
/** Refuse a call that would change an item DSH owns. */
|
|
117
|
+
private refuseTaken;
|
|
118
|
+
}
|
|
119
|
+
export { dshEnvText } from './text.js';
|
|
120
|
+
export { readDshSkills, syncSkills, skillDirectory, skillFile, dshSkillSource, type DshSkill, type DshSkillSource, } from './skills.js';
|
|
121
|
+
export { serversOfToolNames, serverOfToolName, type DshServer } from './servers.js';
|
package/index.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/env` inside DSH: arbor's own environment, with DSH's skills and MCP servers
|
|
3
|
+
* mounted read-only beside it (docs/environment.md).
|
|
4
|
+
*
|
|
5
|
+
* The environment's home is `<DSH_HOME>/arbor/env`, and it is arbor's own
|
|
6
|
+
* `DirectoryEnvironment`: the settings at `/env/settings.json`, arbor's built-in
|
|
7
|
+
* skills, and everything the model installs itself with `reload` all keep
|
|
8
|
+
* working exactly as they do in any other deployment.
|
|
9
|
+
*
|
|
10
|
+
* What this package adds is DSH's side of the world:
|
|
11
|
+
*
|
|
12
|
+
* - **DSH's skills** are read from `ctx.skills`, DSH's own layered registry,
|
|
13
|
+
* and mounted by writing each one's body to `<env>/skills/<name>/SKILL.md`.
|
|
14
|
+
* The model sees them in `<env>` with that path and reads them with `read`;
|
|
15
|
+
* they are read-only, because a change goes through DSH, not through arbor's
|
|
16
|
+
* `reload`.
|
|
17
|
+
* - **DSH's MCP servers** are listed from the tools DSH publishes under the
|
|
18
|
+
* `mcp__<server>__<tool>` names. Their tools stay DSH tools — the tool bridge
|
|
19
|
+
* brings them in as external tools — so the item declares no functions and no
|
|
20
|
+
* arbor MCP module is created for it. Duplicating them as modules would give
|
|
21
|
+
* the model two ways to call the same tool.
|
|
22
|
+
*
|
|
23
|
+
* The mount is live: `skills/change` re-reads DSH's catalog, so a skill a DSH
|
|
24
|
+
* plugin adds or removes reaches `/env` without a restart.
|
|
25
|
+
*/
|
|
26
|
+
import { arborError } from '@4fu/arbor-errors';
|
|
27
|
+
import { textOf } from '@4fu/arbor-prompt';
|
|
28
|
+
import { DirectoryEnvironment } from '@4fu/arbor-environment-dir';
|
|
29
|
+
import { readDshSkills, syncSkills } from "./skills.js";
|
|
30
|
+
import { serversOfToolNames } from "./servers.js";
|
|
31
|
+
import { dshEnvText } from "./text.js";
|
|
32
|
+
export class DshEnvironment {
|
|
33
|
+
inner;
|
|
34
|
+
skills;
|
|
35
|
+
toolNames;
|
|
36
|
+
cwd;
|
|
37
|
+
seed;
|
|
38
|
+
/** The mounted DSH skills and MCP servers, read the last time `list` ran. */
|
|
39
|
+
mounted = { skills: [], servers: [] };
|
|
40
|
+
/** The names DSH owns, which arbor's own `reload` may not touch. */
|
|
41
|
+
owned = new Set();
|
|
42
|
+
/** Set while this package is writing the mount, so a re-read is not recursive. */
|
|
43
|
+
syncing = false;
|
|
44
|
+
constructor(options) {
|
|
45
|
+
this.inner = options.inner;
|
|
46
|
+
this.skills = options.skills;
|
|
47
|
+
this.toolNames = options.toolNames;
|
|
48
|
+
this.cwd = options.cwd ?? (() => process.cwd());
|
|
49
|
+
this.seed = options.seedSettings;
|
|
50
|
+
// DSH's catalog moving re-reads it, so a skill a plugin adds or removes
|
|
51
|
+
// reaches `/env` without a restart.
|
|
52
|
+
this.skills.onChange(() => {
|
|
53
|
+
if (this.syncing)
|
|
54
|
+
return;
|
|
55
|
+
void this.refresh().catch(() => { });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** The shared mount every window reads `/env` through. */
|
|
59
|
+
get mount() {
|
|
60
|
+
return this.inner.mount;
|
|
61
|
+
}
|
|
62
|
+
/** The directory the mount is, on the host, which the file tools read. */
|
|
63
|
+
get directory() {
|
|
64
|
+
return this.inner.directory;
|
|
65
|
+
}
|
|
66
|
+
/** Every item: arbor's own, then DSH's mounted ones. */
|
|
67
|
+
async list() {
|
|
68
|
+
await this.refresh();
|
|
69
|
+
return [...await this.inner.list(), ...this.dshItems()];
|
|
70
|
+
}
|
|
71
|
+
/** Re-read DSH's skills and MCP servers, and write the mount. */
|
|
72
|
+
async refresh() {
|
|
73
|
+
this.syncing = true;
|
|
74
|
+
try {
|
|
75
|
+
const cwd = this.cwd();
|
|
76
|
+
const skills = await readDshSkills(this.skills, cwd);
|
|
77
|
+
const servers = serversOfToolNames(this.toolNames());
|
|
78
|
+
await syncSkills(this.inner.directory, skills);
|
|
79
|
+
this.mounted = { skills, servers };
|
|
80
|
+
this.owned = new Set([
|
|
81
|
+
...skills.map(skill => `skill:${skill.name}`),
|
|
82
|
+
...servers.map(server => `mcp:${server.name}`),
|
|
83
|
+
]);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
this.syncing = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** The items DSH owns, as arbor's environment lists them. */
|
|
90
|
+
dshItems() {
|
|
91
|
+
const items = [];
|
|
92
|
+
for (const skill of this.mounted.skills) {
|
|
93
|
+
items.push({
|
|
94
|
+
ref: `dsh:skill:${skill.name}`,
|
|
95
|
+
kind: 'skill',
|
|
96
|
+
name: skill.name,
|
|
97
|
+
description: this.text('env.dsh.skill', {
|
|
98
|
+
name: skill.name,
|
|
99
|
+
description: skill.description,
|
|
100
|
+
path: `${this.mount}/skills/${skill.name}/SKILL.md`,
|
|
101
|
+
}),
|
|
102
|
+
path: `${this.mount}/skills/${skill.name}/SKILL.md`,
|
|
103
|
+
revision: 1,
|
|
104
|
+
time: 0,
|
|
105
|
+
builtin: true,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
for (const server of this.mounted.servers) {
|
|
109
|
+
items.push({
|
|
110
|
+
ref: `dsh:mcp:${server.name}`,
|
|
111
|
+
kind: 'mcp',
|
|
112
|
+
name: server.name,
|
|
113
|
+
description: server.tools.length === 0
|
|
114
|
+
? this.text('env.dsh.server.notools', { name: server.name })
|
|
115
|
+
: this.text('env.dsh.server', { name: server.name, tools: server.tools.join('、') }),
|
|
116
|
+
// No functions: the tools stay DSH tools, and an arbor MCP module here
|
|
117
|
+
// would be a second way to call the same thing.
|
|
118
|
+
functions: [],
|
|
119
|
+
revision: 1,
|
|
120
|
+
time: 0,
|
|
121
|
+
builtin: true,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return items;
|
|
125
|
+
}
|
|
126
|
+
/** The revisions, arbor's own: a mounted item has none of its own to give. */
|
|
127
|
+
async history(item) {
|
|
128
|
+
return this.inner.history(item);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The settings: the model's own from `/env/settings.json` once it installs
|
|
132
|
+
* one, and the deployment's seed until then.
|
|
133
|
+
*/
|
|
134
|
+
async settings() {
|
|
135
|
+
return (await this.inner.settings()) ?? this.seed;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Validate drafts, refusing the whole call when one names an item DSH owns.
|
|
139
|
+
*
|
|
140
|
+
* Everything else is the inner environment's own validation, so a skill
|
|
141
|
+
* parses and an MCP server starts and lists its functions exactly as it does
|
|
142
|
+
* in any other deployment.
|
|
143
|
+
*/
|
|
144
|
+
async validate(drafts) {
|
|
145
|
+
const taken = this.takenBy(drafts);
|
|
146
|
+
if (taken.length === 0)
|
|
147
|
+
return this.inner.validate(drafts);
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
items: drafts.map(draft => ({
|
|
151
|
+
name: draft.name,
|
|
152
|
+
ok: false,
|
|
153
|
+
error: this.readonly(draft.kind, draft.name),
|
|
154
|
+
})),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** Install drafts, refusing one that names an item DSH owns. */
|
|
158
|
+
async install(drafts, remove) {
|
|
159
|
+
this.refuseTaken([...this.takenBy(drafts), ...this.namedBy(remove)]);
|
|
160
|
+
return this.inner.install(drafts, remove);
|
|
161
|
+
}
|
|
162
|
+
/** Remove items, refusing one that names an item DSH owns. */
|
|
163
|
+
async remove(items) {
|
|
164
|
+
this.refuseTaken(this.namedBy(items));
|
|
165
|
+
return this.inner.remove(items);
|
|
166
|
+
}
|
|
167
|
+
/** What changed since a revision. DSH's mounted items never change one. */
|
|
168
|
+
async delta(since) {
|
|
169
|
+
return this.inner.delta(since);
|
|
170
|
+
}
|
|
171
|
+
/** The revision the environment stands at, arbor's own. */
|
|
172
|
+
async revision() {
|
|
173
|
+
return this.inner.revision();
|
|
174
|
+
}
|
|
175
|
+
/** Start every installed service again, after a host restart. */
|
|
176
|
+
async resume() {
|
|
177
|
+
await this.refresh().catch(() => { });
|
|
178
|
+
return this.inner.resume();
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Call one function of an installed MCP server.
|
|
182
|
+
*
|
|
183
|
+
* A mounted DSH server has no functions here — its tools are DSH tools and
|
|
184
|
+
* run through the tool bridge — so a call naming one is refused rather than
|
|
185
|
+
* silently sent somewhere else.
|
|
186
|
+
*/
|
|
187
|
+
async call(server, fn, args) {
|
|
188
|
+
if (this.mounted.servers.some(one => one.name === server)) {
|
|
189
|
+
throw arborError({
|
|
190
|
+
code: 'invalid',
|
|
191
|
+
rule: 'env.dsh.server.not_a_module',
|
|
192
|
+
message: this.text('refused.env.dsh.readonly', { kind: 'MCP 服务器', name: server }),
|
|
193
|
+
nextAction: this.text('next.env.dsh.readonly'),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return this.inner.call(server, fn, args);
|
|
197
|
+
}
|
|
198
|
+
/** One refusal naming an item DSH owns, in this package's own words. */
|
|
199
|
+
readonly(kind, name) {
|
|
200
|
+
return arborError({
|
|
201
|
+
code: 'invalid',
|
|
202
|
+
rule: 'env.dsh.readonly',
|
|
203
|
+
message: this.text('refused.env.dsh.readonly', { kind: kindLabel(kind), name }),
|
|
204
|
+
nextAction: this.text('next.env.dsh.readonly'),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** One fixed string, from this package's own table. */
|
|
208
|
+
text(key, args = {}) {
|
|
209
|
+
return textOf(dshEnvText, key, args, key);
|
|
210
|
+
}
|
|
211
|
+
/** The drafts whose names DSH owns, as `kind:name` entries. */
|
|
212
|
+
takenBy(drafts) {
|
|
213
|
+
return drafts.map(draft => `${draft.kind}:${draft.name}`).filter(name => this.owned.has(name));
|
|
214
|
+
}
|
|
215
|
+
/** The item names a removal list names, as `kind:name` entries. */
|
|
216
|
+
namedBy(items) {
|
|
217
|
+
if (items === undefined)
|
|
218
|
+
return [];
|
|
219
|
+
const names = [];
|
|
220
|
+
for (const item of items) {
|
|
221
|
+
const text = String(item);
|
|
222
|
+
for (const candidate of [text, ...kinds().map(kind => `${kind}:${text}`)]) {
|
|
223
|
+
if (this.owned.has(candidate))
|
|
224
|
+
names.push(candidate);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return names;
|
|
228
|
+
}
|
|
229
|
+
/** Refuse a call that would change an item DSH owns. */
|
|
230
|
+
refuseTaken(taken) {
|
|
231
|
+
const first = taken[0];
|
|
232
|
+
if (first === undefined)
|
|
233
|
+
return;
|
|
234
|
+
const [kind, name] = first.split(':');
|
|
235
|
+
throw this.readonly(kind, name ?? '');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Every item kind, for reading a bare name a removal list may carry. */
|
|
239
|
+
function kinds() {
|
|
240
|
+
return ['skill', 'mcp', 'service', 'settings'];
|
|
241
|
+
}
|
|
242
|
+
/** One kind, as the refusal names it. */
|
|
243
|
+
function kindLabel(kind) {
|
|
244
|
+
return kind === 'mcp' ? 'MCP 服务器' : kind === 'skill' ? '技能' : kind === 'service' ? '服务' : '设置';
|
|
245
|
+
}
|
|
246
|
+
export { dshEnvText } from "./text.js";
|
|
247
|
+
export { readDshSkills, syncSkills, skillDirectory, skillFile, dshSkillSource, } from "./skills.js";
|
|
248
|
+
export { serversOfToolNames, serverOfToolName } from "./servers.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@4fu/dsh-arbor-env",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "2026.1110.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "/env for arbor inside DSH: DSH's skills and MCP servers mounted read-only into arbor's environment, beside the settings, built-in skills and the model's own installs under DSH_HOME/arbor",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./index.d.ts",
|
|
9
|
+
"default": "./index.js"
|
|
10
|
+
},
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@4fu/arbor-environment-dir": "2026.1109.1",
|
|
15
|
+
"@4fu/arbor-errors": "2026.1107.0",
|
|
16
|
+
"@4fu/arbor-interfaces": "2026.1109.1",
|
|
17
|
+
"@4fu/arbor-prompt": "2026.1109.1",
|
|
18
|
+
"@deepseek-ai/dsh-skill": "0.2.2-rc.1",
|
|
19
|
+
"@deepseek-ai/dsh-tools": "0.2.2-rc.1"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/4fuu/dsh-arbor.git",
|
|
25
|
+
"directory": "packages/arbor-env"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=24.9.0",
|
|
29
|
+
"dsh": "0.2.2-rc.1"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"index.d.ts",
|
|
36
|
+
"index.js",
|
|
37
|
+
"servers.d.ts",
|
|
38
|
+
"servers.js",
|
|
39
|
+
"skills.d.ts",
|
|
40
|
+
"skills.js",
|
|
41
|
+
"text.d.ts",
|
|
42
|
+
"text.js"
|
|
43
|
+
],
|
|
44
|
+
"types": "./index.d.ts",
|
|
45
|
+
"main": "./index.js"
|
|
46
|
+
}
|
package/servers.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH's MCP servers, as arbor's environment sees them.
|
|
3
|
+
*
|
|
4
|
+
* DSH has no MCP registry service to ask: an MCP server is a `mcp-client`
|
|
5
|
+
* plugin row, and what it publishes is its tools on `ctx.tools` under the
|
|
6
|
+
* server-qualified public name `mcp__<serverName>__<rawName>`
|
|
7
|
+
* (`@deepseek-ai/dsh-mcp-client`, `lib/index.js`, `publicToolName`; the config's
|
|
8
|
+
* `serverName` must match `[A-Za-z0-9_-]{1,32}`, so the segment between the
|
|
9
|
+
* first `mcp__` and the next `__` is always the server's own name). It also
|
|
10
|
+
* contributes one system-prompt section named `mcp:<serverName>`
|
|
11
|
+
* (`registerServerContext`), which is third-party content the prompt bridge
|
|
12
|
+
* carries into `<dsh>`.
|
|
13
|
+
*
|
|
14
|
+
* So the server list is read off the tool names, which is the same thing the
|
|
15
|
+
* tool bridge bridges: a DSH MCP tool arrives in arbor as an external tool with
|
|
16
|
+
* that name, and it is never restated as an arbor MCP module. The item listed
|
|
17
|
+
* here therefore declares no functions — an arbor MCP module would give the
|
|
18
|
+
* model a second way to call the same tool, which is exactly what must not
|
|
19
|
+
* happen.
|
|
20
|
+
*/
|
|
21
|
+
/** One DSH MCP server, as this package lists it. */
|
|
22
|
+
export interface DshServer {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
/** The public names of the tools this server published, as DSH names them. */
|
|
25
|
+
readonly tools: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The server name one public tool name carries, or `undefined` when the name is
|
|
29
|
+
* not an MCP tool's.
|
|
30
|
+
*
|
|
31
|
+
* The server's own name is `[A-Za-z0-9_-]{1,32}` by config, so the first `__`
|
|
32
|
+
* after the prefix ends it — including in the lossy-normalized case, where a
|
|
33
|
+
* hash is appended after the raw name rather than inside the server's segment.
|
|
34
|
+
*/
|
|
35
|
+
export declare function serverOfToolName(name: string): string | undefined;
|
|
36
|
+
/** Every MCP server the named DSH tools belong to, by server name. */
|
|
37
|
+
export declare function serversOfToolNames(names: readonly string[]): readonly DshServer[];
|
package/servers.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH's MCP servers, as arbor's environment sees them.
|
|
3
|
+
*
|
|
4
|
+
* DSH has no MCP registry service to ask: an MCP server is a `mcp-client`
|
|
5
|
+
* plugin row, and what it publishes is its tools on `ctx.tools` under the
|
|
6
|
+
* server-qualified public name `mcp__<serverName>__<rawName>`
|
|
7
|
+
* (`@deepseek-ai/dsh-mcp-client`, `lib/index.js`, `publicToolName`; the config's
|
|
8
|
+
* `serverName` must match `[A-Za-z0-9_-]{1,32}`, so the segment between the
|
|
9
|
+
* first `mcp__` and the next `__` is always the server's own name). It also
|
|
10
|
+
* contributes one system-prompt section named `mcp:<serverName>`
|
|
11
|
+
* (`registerServerContext`), which is third-party content the prompt bridge
|
|
12
|
+
* carries into `<dsh>`.
|
|
13
|
+
*
|
|
14
|
+
* So the server list is read off the tool names, which is the same thing the
|
|
15
|
+
* tool bridge bridges: a DSH MCP tool arrives in arbor as an external tool with
|
|
16
|
+
* that name, and it is never restated as an arbor MCP module. The item listed
|
|
17
|
+
* here therefore declares no functions — an arbor MCP module would give the
|
|
18
|
+
* model a second way to call the same tool, which is exactly what must not
|
|
19
|
+
* happen.
|
|
20
|
+
*/
|
|
21
|
+
/** The public-name prefix every DSH MCP tool carries. */
|
|
22
|
+
const MCP_PREFIX = 'mcp__';
|
|
23
|
+
/**
|
|
24
|
+
* The server name one public tool name carries, or `undefined` when the name is
|
|
25
|
+
* not an MCP tool's.
|
|
26
|
+
*
|
|
27
|
+
* The server's own name is `[A-Za-z0-9_-]{1,32}` by config, so the first `__`
|
|
28
|
+
* after the prefix ends it — including in the lossy-normalized case, where a
|
|
29
|
+
* hash is appended after the raw name rather than inside the server's segment.
|
|
30
|
+
*/
|
|
31
|
+
export function serverOfToolName(name) {
|
|
32
|
+
if (!name.startsWith(MCP_PREFIX))
|
|
33
|
+
return undefined;
|
|
34
|
+
const rest = name.slice(MCP_PREFIX.length);
|
|
35
|
+
const cut = rest.indexOf('__');
|
|
36
|
+
if (cut <= 0)
|
|
37
|
+
return undefined;
|
|
38
|
+
return rest.slice(0, cut);
|
|
39
|
+
}
|
|
40
|
+
/** Every MCP server the named DSH tools belong to, by server name. */
|
|
41
|
+
export function serversOfToolNames(names) {
|
|
42
|
+
const byServer = new Map();
|
|
43
|
+
for (const name of names) {
|
|
44
|
+
const server = serverOfToolName(name);
|
|
45
|
+
if (server === undefined)
|
|
46
|
+
continue;
|
|
47
|
+
byServer.set(server, [...(byServer.get(server) ?? []), name]);
|
|
48
|
+
}
|
|
49
|
+
return [...byServer]
|
|
50
|
+
.map(([name, tools]) => ({ name, tools: tools.sort() }))
|
|
51
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
52
|
+
}
|
package/skills.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH's skills, as arbor's environment sees them.
|
|
3
|
+
*
|
|
4
|
+
* DSH owns its skill catalog: `ctx.skills` is the layered `SkillRegistry`
|
|
5
|
+
* (`@deepseek-ai/dsh-skill`, `lib/types/index.d.ts`), whose `list` answers with
|
|
6
|
+
* the winning summaries for a working directory and whose `get` loads one
|
|
7
|
+
* skill's body. The shipped `headless` profile mounts the registry plus the
|
|
8
|
+
* filesystem provider (`packages/bundle/base/cordis.patch.yml`, rows `skill`
|
|
9
|
+
* and `skill-filesystem`) and the `skill` tool (`tool-skill`).
|
|
10
|
+
*
|
|
11
|
+
* Arbor's environment is a directory of `SKILL.md` files a window reads at
|
|
12
|
+
* `/env` (`@4fu/arbor-environment-dir`), so a DSH skill is mounted by writing
|
|
13
|
+
* its body to `<env>/skills/<name>/SKILL.md` and listing it with that path. The
|
|
14
|
+
* body is DSH's own — the markdown after its frontmatter — and the description
|
|
15
|
+
* on the listing is DSH's own, carried verbatim rather than restated.
|
|
16
|
+
*
|
|
17
|
+
* What is staged is the instruction file and nothing else: a skill's other
|
|
18
|
+
* files stay where DSH keeps them, and a window reaches them by their absolute
|
|
19
|
+
* path, which every sandbox mode permits reading.
|
|
20
|
+
*/
|
|
21
|
+
import type { SkillDefinition, SkillSummary } from '@deepseek-ai/dsh-skill';
|
|
22
|
+
/** One DSH skill, as this package mounts it. */
|
|
23
|
+
export interface DshSkill {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly description: string;
|
|
26
|
+
/** The skill's markdown body, after its frontmatter. */
|
|
27
|
+
readonly body: string;
|
|
28
|
+
}
|
|
29
|
+
/** What the DSH side of the mount answers with. */
|
|
30
|
+
export interface DshSkillSource {
|
|
31
|
+
/** Every model-invocable skill DSH offers for one working directory. */
|
|
32
|
+
list(cwd: string): Promise<readonly SkillSummary[]>;
|
|
33
|
+
/** One skill's body, or `undefined` when it is no longer loadable. */
|
|
34
|
+
get(name: string, cwd: string): Promise<SkillDefinition | undefined>;
|
|
35
|
+
/** Hear that DSH's skill catalog may have changed; returns the disposer. */
|
|
36
|
+
onChange(listener: () => void): () => void;
|
|
37
|
+
}
|
|
38
|
+
/** The skill source over DSH's own registry. */
|
|
39
|
+
export declare function dshSkillSource(skills: {
|
|
40
|
+
list(options?: {
|
|
41
|
+
cwd?: string;
|
|
42
|
+
}): Promise<readonly SkillSummary[]>;
|
|
43
|
+
get(name: string, options?: {
|
|
44
|
+
cwd?: string;
|
|
45
|
+
}): Promise<SkillDefinition | undefined>;
|
|
46
|
+
}, onChange: (listener: () => void) => () => void): DshSkillSource;
|
|
47
|
+
/**
|
|
48
|
+
* The directory the mounted skills live in, under the environment's own.
|
|
49
|
+
*
|
|
50
|
+
* `<env>/skills/<name>/SKILL.md` is the layout arbor's environment already uses
|
|
51
|
+
* (`@4fu/arbor-environment-dir`, `directory-environment.d.ts`), so a mounted
|
|
52
|
+
* skill sits beside the model's own and arbor's built-in ones and reads the same
|
|
53
|
+
* way.
|
|
54
|
+
*/
|
|
55
|
+
export declare function skillDirectory(environmentDirectory: string, name: string): string;
|
|
56
|
+
/** One mounted skill's `SKILL.md`: DSH's frontmatter, then DSH's body. */
|
|
57
|
+
export declare function skillFile(skill: DshSkill): string;
|
|
58
|
+
/**
|
|
59
|
+
* Write the current DSH skills into the environment's own directory, and remove
|
|
60
|
+
* the mounted ones that are gone.
|
|
61
|
+
*
|
|
62
|
+
* The environment's directory is the host's own data under `DSH_HOME/arbor`,
|
|
63
|
+
* the same place the journal and the note store live, so it is written with
|
|
64
|
+
* `node:fs` like they are: it is not the session's workspace and the sandbox
|
|
65
|
+
* that fences a window's files does not govern it.
|
|
66
|
+
*/
|
|
67
|
+
export declare function syncSkills(environmentDirectory: string, skills: readonly DshSkill[]): Promise<void>;
|
|
68
|
+
/** Read the current DSH skills for one working directory. */
|
|
69
|
+
export declare function readDshSkills(source: DshSkillSource, cwd: string): Promise<readonly DshSkill[]>;
|
package/skills.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH's skills, as arbor's environment sees them.
|
|
3
|
+
*
|
|
4
|
+
* DSH owns its skill catalog: `ctx.skills` is the layered `SkillRegistry`
|
|
5
|
+
* (`@deepseek-ai/dsh-skill`, `lib/types/index.d.ts`), whose `list` answers with
|
|
6
|
+
* the winning summaries for a working directory and whose `get` loads one
|
|
7
|
+
* skill's body. The shipped `headless` profile mounts the registry plus the
|
|
8
|
+
* filesystem provider (`packages/bundle/base/cordis.patch.yml`, rows `skill`
|
|
9
|
+
* and `skill-filesystem`) and the `skill` tool (`tool-skill`).
|
|
10
|
+
*
|
|
11
|
+
* Arbor's environment is a directory of `SKILL.md` files a window reads at
|
|
12
|
+
* `/env` (`@4fu/arbor-environment-dir`), so a DSH skill is mounted by writing
|
|
13
|
+
* its body to `<env>/skills/<name>/SKILL.md` and listing it with that path. The
|
|
14
|
+
* body is DSH's own — the markdown after its frontmatter — and the description
|
|
15
|
+
* on the listing is DSH's own, carried verbatim rather than restated.
|
|
16
|
+
*
|
|
17
|
+
* What is staged is the instruction file and nothing else: a skill's other
|
|
18
|
+
* files stay where DSH keeps them, and a window reaches them by their absolute
|
|
19
|
+
* path, which every sandbox mode permits reading.
|
|
20
|
+
*/
|
|
21
|
+
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
/** The skill source over DSH's own registry. */
|
|
24
|
+
export function dshSkillSource(skills, onChange) {
|
|
25
|
+
return {
|
|
26
|
+
list: cwd => skills.list({ cwd }),
|
|
27
|
+
get: (name, cwd) => skills.get(name, { cwd }),
|
|
28
|
+
onChange,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The directory the mounted skills live in, under the environment's own.
|
|
33
|
+
*
|
|
34
|
+
* `<env>/skills/<name>/SKILL.md` is the layout arbor's environment already uses
|
|
35
|
+
* (`@4fu/arbor-environment-dir`, `directory-environment.d.ts`), so a mounted
|
|
36
|
+
* skill sits beside the model's own and arbor's built-in ones and reads the same
|
|
37
|
+
* way.
|
|
38
|
+
*/
|
|
39
|
+
export function skillDirectory(environmentDirectory, name) {
|
|
40
|
+
return join(environmentDirectory, 'skills', name, 'SKILL.md');
|
|
41
|
+
}
|
|
42
|
+
/** One mounted skill's `SKILL.md`: DSH's frontmatter, then DSH's body. */
|
|
43
|
+
export function skillFile(skill) {
|
|
44
|
+
return `---\nname: ${skill.name}\ndescription: ${skill.description.replaceAll('\n', ' ')}\n---\n\n${skill.body}\n`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Write the current DSH skills into the environment's own directory, and remove
|
|
48
|
+
* the mounted ones that are gone.
|
|
49
|
+
*
|
|
50
|
+
* The environment's directory is the host's own data under `DSH_HOME/arbor`,
|
|
51
|
+
* the same place the journal and the note store live, so it is written with
|
|
52
|
+
* `node:fs` like they are: it is not the session's workspace and the sandbox
|
|
53
|
+
* that fences a window's files does not govern it.
|
|
54
|
+
*/
|
|
55
|
+
export async function syncSkills(environmentDirectory, skills) {
|
|
56
|
+
const wanted = new Set(skills.map(skill => skill.name));
|
|
57
|
+
const root = join(environmentDirectory, 'skills');
|
|
58
|
+
await mkdir(root, { recursive: true });
|
|
59
|
+
for (const skill of skills) {
|
|
60
|
+
const path = skillDirectory(environmentDirectory, skill.name);
|
|
61
|
+
await mkdir(dirname(path), { recursive: true });
|
|
62
|
+
await writeFile(path, skillFile(skill), 'utf8');
|
|
63
|
+
}
|
|
64
|
+
// A mounted skill DSH no longer offers is removed, so `/env` says what is
|
|
65
|
+
// true now rather than what was true when the process started.
|
|
66
|
+
const { readdir } = await import('node:fs/promises');
|
|
67
|
+
for (const entry of await readdir(root, { withFileTypes: true }).catch(() => [])) {
|
|
68
|
+
if (!entry.isDirectory() || wanted.has(entry.name))
|
|
69
|
+
continue;
|
|
70
|
+
await rm(join(root, entry.name), { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Read the current DSH skills for one working directory. */
|
|
74
|
+
export async function readDshSkills(source, cwd) {
|
|
75
|
+
const summaries = await source.list(cwd);
|
|
76
|
+
const skills = [];
|
|
77
|
+
for (const summary of summaries) {
|
|
78
|
+
if (summary.invocation?.modelInvocable === false)
|
|
79
|
+
continue;
|
|
80
|
+
const definition = await source.get(summary.name, cwd);
|
|
81
|
+
skills.push({
|
|
82
|
+
name: summary.name,
|
|
83
|
+
description: summary.description,
|
|
84
|
+
body: definition?.content ?? '',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return skills;
|
|
88
|
+
}
|
package/text.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing text this environment contributes.
|
|
3
|
+
*
|
|
4
|
+
* Every fixed string this package writes lives here, in Chinese, one entry per
|
|
5
|
+
* string. The host merges this table over its own, so the wording of a mounted
|
|
6
|
+
* DSH skill's line and of a refusal is the deployment's.
|
|
7
|
+
*
|
|
8
|
+
* A mounted item's own description is DSH's, carried verbatim: a skill's
|
|
9
|
+
* frontmatter description and an MCP server's are third-party content, and
|
|
10
|
+
* restating them would put this package's words where DSH's belong.
|
|
11
|
+
*/
|
|
12
|
+
import type { TextTable } from '@4fu/arbor-interfaces';
|
|
13
|
+
/** This package's own fixed strings. */
|
|
14
|
+
export declare const dshEnvText: TextTable;
|
package/text.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing text this environment contributes.
|
|
3
|
+
*
|
|
4
|
+
* Every fixed string this package writes lives here, in Chinese, one entry per
|
|
5
|
+
* string. The host merges this table over its own, so the wording of a mounted
|
|
6
|
+
* DSH skill's line and of a refusal is the deployment's.
|
|
7
|
+
*
|
|
8
|
+
* A mounted item's own description is DSH's, carried verbatim: a skill's
|
|
9
|
+
* frontmatter description and an MCP server's are third-party content, and
|
|
10
|
+
* restating them would put this package's words where DSH's belong.
|
|
11
|
+
*/
|
|
12
|
+
/** This package's own fixed strings. */
|
|
13
|
+
export const dshEnvText = {
|
|
14
|
+
// ---- what a mounted item is -------------------------------------------
|
|
15
|
+
'env.dsh.skill': 'DSH 的技能 {name}(只读):{description}({path})。'
|
|
16
|
+
+ '技能自带的其它文件仍在 DSH 那边,按绝对路径用 read 或 bash 读。',
|
|
17
|
+
'env.dsh.server': 'DSH 的 MCP 服务器 {name}(只读):它的工具作为 DSH 工具直接调用'
|
|
18
|
+
+ '({tools}),不是本环境的模块函数,也不要为它再装一个 MCP 服务器。',
|
|
19
|
+
'env.dsh.server.notools': 'DSH 的 MCP 服务器 {name}(只读):它当前没有发布任何工具。',
|
|
20
|
+
// ---- refusals ---------------------------------------------------------
|
|
21
|
+
'refused.env.dsh.readonly': '{kind} {name} 是 DSH 挂进来的,只读:不能用 reload 增删改它。',
|
|
22
|
+
'next.env.dsh.readonly': '要改 DSH 这边的技能或 MCP 服务器,请在 DSH 里改;'
|
|
23
|
+
+ '本环境只接受模型自己安装的技能、MCP 服务器和服务。',
|
|
24
|
+
};
|