@deepseek-ai/dsh-skill-filesystem 0.0.1-rc.3
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 +28 -0
- package/README.i18n.yaml +6 -0
- package/README.md +75 -0
- package/README.zh.md +75 -0
- package/lib/index.js +880 -0
- package/lib/invariant.js +23 -0
- package/lib/types/index.d.ts +84 -0
- package/lib/types/invariant.d.ts +16 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, DeepSeek
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
|
+
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
|
+
# after editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/skill/skill-filesystem/README.md
|
|
5
|
+
README.md: 33fb550a9ca804c4f0f380232c2bfd94cc58a67e
|
|
6
|
+
README.zh.md: 4c09111fc2d31bf5467b57f90f65cf68e298da09
|
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# @deepseek-ai/dsh-skill-filesystem
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
Local filesystem provider for the `ctx.skills` registry.
|
|
6
|
+
|
|
7
|
+
This package implements one skill source. It scans local project, custom, and user skill roots, parses `SKILL.md` or flat Markdown skill files, and registers the provider on `ctx.skills`. The registry remains in `@deepseek-ai/dsh-skill`; the durable session catalogs and model-facing loader tool remain in `@deepseek-ai/dsh-tool-skill`.
|
|
8
|
+
|
|
9
|
+
## Plugin
|
|
10
|
+
|
|
11
|
+
Requires `ctx.skills` (`inject: ['skills']`).
|
|
12
|
+
|
|
13
|
+
### Config
|
|
14
|
+
|
|
15
|
+
| Field | Default | Meaning |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `providerName` | `filesystem` | Unique name used to register this provider on `ctx.skills`. |
|
|
18
|
+
| `includeDefaultRoots` | `true` | Include project and user roots around `customSkillDirs`; set false for an isolated custom-root provider. |
|
|
19
|
+
| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root resolved by [`@deepseek-ai/dsh-home-paths`](../../util/home-paths/README.md); scans `skills` under this directory. |
|
|
20
|
+
| `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. |
|
|
21
|
+
| `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. |
|
|
22
|
+
| `watch` | `true` | Watch host-local roots and invalidate the local provider when catalog membership or frontmatter may have changed. |
|
|
23
|
+
| `watchUsePolling` | `false` | Use Chokidar polling instead of native events for existing skill roots. |
|
|
24
|
+
| `watchStabilityThresholdMs` | `200` | Stable-write window for Chokidar `add` and `change` events. |
|
|
25
|
+
| `watchPollIntervalMs` | `100` | Chokidar polling/stability interval and missing-path probe interval. |
|
|
26
|
+
| `watchMaxProjects` | `128` | Maximum distinct project roots retained in the watcher LRU. |
|
|
27
|
+
| `watchFollowSymlinks` | `true` | Follow symbolic links while watching existing roots. |
|
|
28
|
+
|
|
29
|
+
## Discovery
|
|
30
|
+
|
|
31
|
+
Default roots are resolved in this provider's rank order:
|
|
32
|
+
|
|
33
|
+
| Rank | Source | Path |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| 100 | `project-dsh` | `<projectRoot>/.dsh/skills` |
|
|
36
|
+
| 200 | `project-agents` | `<projectRoot>/.agents/skills` |
|
|
37
|
+
| 300 | `custom` | `Config.customSkillDirs` |
|
|
38
|
+
| 400 | `user-dsh` | `<dshHome>/skills` |
|
|
39
|
+
| 500 | `user-agents` | `<agentsHome>/skills` |
|
|
40
|
+
|
|
41
|
+
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. `includeDefaultRoots: false` omits the project and user rows and the `$DSH_BUNDLED_SKILL_DIR` environment default while retaining explicitly configured custom and bundled roots, allowing several uniquely named isolated providers to see only their own roots. This provider supplies project and user skills; another provider may supply built-in system skills.
|
|
42
|
+
|
|
43
|
+
When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Confirmed missing paths are valid empty state, malformed or non-text entries warn and skip, and unexpected discovery/read failures make the registry snapshot incomplete rather than replacing a last-good model catalog with a misleading deletion.
|
|
44
|
+
|
|
45
|
+
## Catalog Change Detection
|
|
46
|
+
|
|
47
|
+
Existing skill roots are watched with Chokidar. Before opening a native watcher, the provider realpaths the existing root or ancestor and restores the next missing segment; when `watchFollowSymlinks` is false and the root itself is a symbolic link, it preserves that final link so Chokidar can enforce the configured boundary. Discovery and diagnostics retain the configured path, while Windows cannot otherwise mix an 8.3 alias with long-form libuv events. The provider observes direct bundle directory additions/removals, flat Markdown additions/removals, and direct `SKILL.md` additions/removals/changes; `change` exists to rediscover catalog frontmatter such as `name` and `description`. Changes below `references`, `scripts`, `assets`, or other bundle resources do not invalidate the catalog. Events delivered in the same microtask batch collapse to one provider invalidation.
|
|
48
|
+
|
|
49
|
+
A root that does not exist is followed from the nearest existing ancestor one missing path segment at a time. The next segment is probed with `fs.watchFile`; once `.agents`, `skills`, or the configured root appears, observation advances until Chokidar can attach to the real root. Root deletion reverses this process, so deleting and recreating an entire skills directory remains observable. Project-scoped watchers are bounded by `watchMaxProjects`; revisiting an evicted project reattaches observation during discovery.
|
|
50
|
+
|
|
51
|
+
The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Existing-root watchers remain persistent until effect teardown so Chokidar owns asynchronous native error events; startup/runtime watcher failures are logged and retried. Discovery still scans readable roots and returns their candidates for direct loading, but marks the observation incomplete so it is not cached or published as an authoritative model catalog. Effect teardown closes every watcher and contains late callbacks.
|
|
52
|
+
|
|
53
|
+
## Skill Format
|
|
54
|
+
|
|
55
|
+
Skills can be single-level directory bundles (`<name>/SKILL.md`) or flat Markdown files (`<name>.md`). Nested `**/SKILL.md` discovery is deliberately excluded. Frontmatter is parsed as an open YAML object with the `yaml` package; this provider interprets required `name` and `description`, plus optional `whenToUse`, `metadata`, `disable-model-invocation`, and `user-invocable`. Names must be kebab-case.
|
|
56
|
+
|
|
57
|
+
The two invocation fields accept YAML booleans and the case-insensitive forms `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`. `disable-model-invocation: true` excludes the skill from model-facing catalogs and loaders; `user-invocable: false` excludes it from human-facing commands. Each omitted field defaults to permitting its surface, and the provider always emits both positive internal policy values, including when both keys are absent. A rejected camel-case spelling or a non-boolean invocation value drops the entire skill from discovery with a warning instead of discarding only that field or falling back to a permissive default. Invocation policy fails closed because ignoring invalid data could expose a skill on a disabled surface; wrong-typed optional `whenToUse` and `metadata` values are omitted because neither currently grants invocation.
|
|
58
|
+
|
|
59
|
+
The catalog and body have separate lifecycles. Discovery parses frontmatter to produce the summary. Every `skill(name)` load rereads and reparses the current file, so body edits need no hash, revision, cache invalidation, or proactive model notification. A frontmatter rename between discovery and loading rejects the stale name and invalidates the provider; the next catalog observation publishes the new name.
|
|
60
|
+
|
|
61
|
+
## Model Experience
|
|
62
|
+
|
|
63
|
+
Indirectly, through `dsh-tool-skill`, which renders this provider's invocable names and capped descriptions into the initial or replacement catalog and a selected current instruction body plus resource-base guidance into retained tool history while paths, provider ranks, and disabled skills remain hidden.
|
|
64
|
+
|
|
65
|
+
#### KV Cache effect
|
|
66
|
+
|
|
67
|
+
Watcher invalidation can cause the named consumer to append a replacement catalog to the existing request history. Body-only edits leave the catalog digest unchanged.
|
|
68
|
+
|
|
69
|
+
## Known Limitations and Deferred Work
|
|
70
|
+
|
|
71
|
+
- **Discovery is one level deep** — only `<root>/<name>/SKILL.md` and `<root>/<name>.md` are recognized; nested skill trees and package manifests are ignored.
|
|
72
|
+
- **Project scope is the nearest `.git` ancestor** — workspaces without that marker fall back to the supplied cwd, with no alternate project-root marker or monorepo subproject selection.
|
|
73
|
+
- **Malformed entries disappear with a warning** — the model catalog receives no per-skill diagnostic and cannot distinguish an absent skill from an invalid one; unexpected I/O failures preserve the last-good catalog instead.
|
|
74
|
+
- **Missing-root observation polls one path segment** — roots absent at startup use `fs.watchFile` at `watchPollIntervalMs` until Chokidar can attach, trading bounded detection latency for reliable creation detection across IDE, Git, and shell workflows.
|
|
75
|
+
- **No body revision protocol** — a loaded body is ordinary retained tool history; later file edits affect later calls but neither rewrite old results nor announce that the body changed.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# @deepseek-ai/dsh-skill-filesystem
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
`ctx.skills` 注册表的本地文件系统提供方。
|
|
6
|
+
|
|
7
|
+
该包实现一个 skill(技能)来源。它扫描本地项目、自定义和用户 skill 根目录,解析 `SKILL.md` 或平铺 Markdown skill 文件,并将提供方注册到 `ctx.skills`。注册表仍位于 `@deepseek-ai/dsh-skill`;持久化会话目录和面向模型的 loader 工具仍位于 `@deepseek-ai/dsh-tool-skill`。
|
|
8
|
+
|
|
9
|
+
## 插件
|
|
10
|
+
|
|
11
|
+
需要 `ctx.skills`(`inject: ['skills']`)。
|
|
12
|
+
|
|
13
|
+
### 配置
|
|
14
|
+
|
|
15
|
+
| 字段 | 默认值 | 含义 |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `providerName` | `filesystem` | 在 `ctx.skills` 上注册该提供方时使用的唯一名称。 |
|
|
18
|
+
| `includeDefaultRoots` | `true` | 在 `customSkillDirs` 周围包含项目根和用户根;设为 false 时仅使用隔离的自定义根。 |
|
|
19
|
+
| `dshHome` | `$DSH_HOME` 或 `~/.dsh` | 由 [`@deepseek-ai/dsh-home-paths`](../../util/home-paths/README.md) 解析的 DeepSeek Harness 配置根目录;扫描该目录下的 `skills`。 |
|
|
20
|
+
| `agentsHome` | `$DSH_AGENTS_HOME` 或 `~/.agents` | 为兼容 skill 扫描的共享 agent(智能体)配置根目录。 |
|
|
21
|
+
| `customSkillDirs` | `[]` | 在项目根目录之后、用户根目录之前扫描的其他本地 skill 根目录。 |
|
|
22
|
+
| `watch` | `true` | 监视宿主本地根,并在目录成员或 frontmatter 可能发生变化时使本地提供方失效。 |
|
|
23
|
+
| `watchUsePolling` | `false` | 对现有 skill 根使用 Chokidar 轮询,而不是原生事件。 |
|
|
24
|
+
| `watchStabilityThresholdMs` | `200` | Chokidar `add` 和 `change` 事件的稳定写入窗口。 |
|
|
25
|
+
| `watchPollIntervalMs` | `100` | Chokidar 轮询/稳定性间隔和缺失路径探测间隔。 |
|
|
26
|
+
| `watchMaxProjects` | `128` | watcher LRU 中保留的不同项目根数量上限。 |
|
|
27
|
+
| `watchFollowSymlinks` | `true` | 监视现有根时跟随符号链接。 |
|
|
28
|
+
|
|
29
|
+
## 发现
|
|
30
|
+
|
|
31
|
+
默认根按该提供方的 rank 顺序解析:
|
|
32
|
+
|
|
33
|
+
| Rank | 来源 | 路径 |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| 100 | `project-dsh` | `<projectRoot>/.dsh/skills` |
|
|
36
|
+
| 200 | `project-agents` | `<projectRoot>/.agents/skills` |
|
|
37
|
+
| 300 | `custom` | `Config.customSkillDirs` |
|
|
38
|
+
| 400 | `user-dsh` | `<dshHome>/skills` |
|
|
39
|
+
| 500 | `user-agents` | `<agentsHome>/skills` |
|
|
40
|
+
|
|
41
|
+
项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目根、用户根以及 `$DSH_BUNDLED_SKILL_DIR` 环境默认值,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个只看到自身根的唯一命名隔离提供方。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。
|
|
42
|
+
|
|
43
|
+
当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;遇到格式错误或非文本条目时,提供方会发出警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录。
|
|
44
|
+
|
|
45
|
+
## 目录变更检测
|
|
46
|
+
|
|
47
|
+
现有 skill 根由 Chokidar 监视。打开原生 watcher 前,提供方会对现有根或祖先执行 realpath 解析,并拼回下一个缺失路径段;当 `watchFollowSymlinks` 为 false 且根本身是符号链接时,提供方不会展开最后这一级链接,使 Chokidar 能够强制执行配置边界。发现与诊断仍保留配置路径,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。提供方会观察直属 bundle 目录的添加/移除、平铺 Markdown 文件的添加/移除,以及直接 `SKILL.md` 的添加/移除/变更;`change` 事件用于重新发现 `name`、`description` 等目录 frontmatter。`references`、`scripts`、`assets` 或其他 bundle 资源下的变更不会使目录失效。同一微任务批次内送达的事件会合并为一次提供方失效。
|
|
48
|
+
|
|
49
|
+
不存在的根会从最近的现有祖先开始,每次沿一个缺失路径段跟踪。系统使用 `fs.watchFile` 探测下一段;当 `.agents`、`skills` 或已配置的根出现后,观察会逐级推进,直至 Chokidar 可以附加到真实根。根删除时,该过程反向执行,因此删除再重建整个 skills 目录仍可被观察到。按项目划分的 watcher 数量受 `watchMaxProjects` 限制;再次访问已被驱逐的项目时,发现阶段会重新附加观察。
|
|
50
|
+
|
|
51
|
+
如果第一方文件系统 `write` 和 `edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher,即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。现有根的 watcher 会保持持久状态直至 effect 释放,使 Chokidar 能够接管异步原生错误事件;watcher 启动或运行时失败会被记录并触发重试。发现过程仍会扫描可读根目录,并返回其候选项供直接加载,但会将观测标记为不完整,因此不会缓存,也不会作为权威模型目录发布。effect 释放会关闭所有 watcher,并收束延迟回调。
|
|
52
|
+
|
|
53
|
+
## skill 格式
|
|
54
|
+
|
|
55
|
+
skill 可以是单层目录 bundle(`<name>/SKILL.md`),也可以是平铺 Markdown 文件(`<name>.md`)。刻意不支持发现嵌套的 `**/SKILL.md`。Frontmatter 使用 `yaml` 包解析为开放的 YAML 对象;该提供方解析必填的 `name` 和 `description`,以及可选的 `whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable`。名称必须使用 kebab-case。
|
|
56
|
+
|
|
57
|
+
这两个调用字段接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`。`disable-model-invocation: true` 会从面向模型的目录和 loader 中排除该 skill;`user-invocable: false` 会从面向用户的命令中排除该 skill。每个省略的字段都默认为允许对应接口调用;提供方始终输出两个正向内部策略值,即使两个键都不存在也不例外。若使用驼峰拼写或提供非布尔调用值,系统会记录警告并从发现结果中排除整个 skill,而不是只丢弃该字段或回退到宽松的默认值。调用策略校验遵循失败时默认拒绝原则,因为忽略无效数据可能会在已禁用的接口上暴露 skill;类型错误的可选 `whenToUse` 和 `metadata` 值则会被省略,因为这两个字段目前都不授予调用权限。
|
|
58
|
+
|
|
59
|
+
目录与正文具有独立的生命周期。发现阶段解析 frontmatter 以生成概述。每次 `skill(name)` 加载都会重新读取并解析当前文件,因此正文编辑不需要 hash、修订号、缓存失效或主动通知模型。若在发现与加载之间更改 frontmatter 中的名称,系统会拒绝陈旧名称并使提供方失效;下一次目录观察会发布新名称。
|
|
60
|
+
|
|
61
|
+
## 模型体验
|
|
62
|
+
|
|
63
|
+
通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有长度上限的描述渲染到初始目录或替换目录中,并将所选的当前指令正文与资源基底指引渲染到保留的工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。
|
|
64
|
+
|
|
65
|
+
#### KV Cache 影响
|
|
66
|
+
|
|
67
|
+
watcher 触发的失效可促使上述消费方在现有请求历史中追加替换目录。仅涉及正文的编辑不会改变目录 digest。
|
|
68
|
+
|
|
69
|
+
## 已知限制与暂缓事项
|
|
70
|
+
|
|
71
|
+
- **发现深度为一层**:只识别 `<root>/<name>/SKILL.md` 和 `<root>/<name>.md`;忽略嵌套 skill 树和包 manifest(元数据清单)。
|
|
72
|
+
- **项目范围为最近 `.git` 祖先**:没有该标记的工作区回退到提供的 cwd,不支持其他项目根标记或 monorepo 子项目选择。
|
|
73
|
+
- **格式错误的条目会随警告消失**:模型目录不会收到每个 skill 的诊断,无法区分缺失的 skill 与无效的 skill;意外 I/O 失败则会保留最后一份可用目录。
|
|
74
|
+
- **缺失根观察每次轮询一个路径段**:启动时不存在的根会使用 `fs.watchFile` 按 `watchPollIntervalMs` 轮询,直至 Chokidar 可以附加;这以有界检测延迟换取跨 IDE、Git 和 shell 工作流的可靠创建检测。
|
|
75
|
+
- **无正文修订协议**:已加载的正文是普通的已保留工具历史;后续文件编辑会影响后续调用,但既不会改写旧结果,也不会通知正文已发生变化。
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
import { access, lstat, readFile, readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { unwatchFile, watchFile } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import chokidar from "chokidar";
|
|
6
|
+
import z from "@deepseek-ai/schemastery";
|
|
7
|
+
import { parse } from "yaml";
|
|
8
|
+
import { canonicalizeWatchPath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
9
|
+
import { BUNDLED_SKILL_RANK, isSkillName } from "@deepseek-ai/dsh-skill";
|
|
10
|
+
//#region lib/types/index.js
|
|
11
|
+
/**
|
|
12
|
+
* Local filesystem skill provider.
|
|
13
|
+
*
|
|
14
|
+
* This package is one implementation of the `ctx.skills` provider registry. It
|
|
15
|
+
* discovers directory-bundle and flat Markdown skills from project, custom, and
|
|
16
|
+
* user roots, parses YAML frontmatter, and loads bodies through `ctx.fs` when a
|
|
17
|
+
* filesystem service is present.
|
|
18
|
+
*
|
|
19
|
+
* @module @deepseek-ai/dsh-skill-filesystem
|
|
20
|
+
*/
|
|
21
|
+
const PROJECT_DSH_RANK = 100;
|
|
22
|
+
const PROJECT_AGENTS_RANK = 200;
|
|
23
|
+
const CUSTOM_RANK = 300;
|
|
24
|
+
const USER_DSH_RANK = 400;
|
|
25
|
+
const USER_AGENTS_RANK = 500;
|
|
26
|
+
const DEFAULT_WATCH_STABILITY_THRESHOLD_MS = 200;
|
|
27
|
+
const DEFAULT_WATCH_POLL_INTERVAL_MS = 100;
|
|
28
|
+
const DEFAULT_WATCH_MAX_PROJECTS = 128;
|
|
29
|
+
const name = "skill-filesystem";
|
|
30
|
+
const inject = ["skills"];
|
|
31
|
+
const Config = z.object({
|
|
32
|
+
providerName: z.string().min(1).default("filesystem"),
|
|
33
|
+
includeDefaultRoots: z.boolean().default(true),
|
|
34
|
+
dshHome: z.string(),
|
|
35
|
+
agentsHome: z.string(),
|
|
36
|
+
customSkillDirs: z.array(z.string()).default([]),
|
|
37
|
+
watch: z.boolean().default(true),
|
|
38
|
+
watchUsePolling: z.boolean().default(false),
|
|
39
|
+
watchStabilityThresholdMs: z.number().default(DEFAULT_WATCH_STABILITY_THRESHOLD_MS),
|
|
40
|
+
watchPollIntervalMs: z.number().default(DEFAULT_WATCH_POLL_INTERVAL_MS),
|
|
41
|
+
watchMaxProjects: z.number().default(DEFAULT_WATCH_MAX_PROJECTS),
|
|
42
|
+
watchFollowSymlinks: z.boolean().default(true),
|
|
43
|
+
bundledSkillDir: z.string()
|
|
44
|
+
});
|
|
45
|
+
/** Register the local filesystem skill provider on `ctx.skills`. */
|
|
46
|
+
function apply(ctx, config = {}) {
|
|
47
|
+
let provider;
|
|
48
|
+
ctx.skills.registerProvider((control) => {
|
|
49
|
+
provider = new FileSystemSkillProvider(ctx, control, config);
|
|
50
|
+
return provider;
|
|
51
|
+
});
|
|
52
|
+
ctx.effect(function* () {
|
|
53
|
+
yield async () => {
|
|
54
|
+
await provider.dispose();
|
|
55
|
+
};
|
|
56
|
+
}, "skill-filesystem watcher");
|
|
57
|
+
ctx.on("fs/observed", (target, _observation, actor) => {
|
|
58
|
+
if (mutationToolName(actor) === void 0) return;
|
|
59
|
+
provider.observeHostMutation(target.displayPath);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Provider that maps local project/user skill roots into `ctx.skills`. */
|
|
63
|
+
var FileSystemSkillProvider = class {
|
|
64
|
+
ctx;
|
|
65
|
+
name;
|
|
66
|
+
includeDefaultRoots;
|
|
67
|
+
dshHome;
|
|
68
|
+
agentsHome;
|
|
69
|
+
customSkillDirs;
|
|
70
|
+
watchManager;
|
|
71
|
+
bundledSkillDir;
|
|
72
|
+
disposal;
|
|
73
|
+
constructor(ctx, control, config = {}) {
|
|
74
|
+
this.ctx = ctx;
|
|
75
|
+
this.name = config.providerName ?? "filesystem";
|
|
76
|
+
this.includeDefaultRoots = config.includeDefaultRoots ?? true;
|
|
77
|
+
this.dshHome = resolveDshHome(config.dshHome);
|
|
78
|
+
this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), ".agents"));
|
|
79
|
+
this.customSkillDirs = (config.customSkillDirs ?? []).map((root) => resolve(root));
|
|
80
|
+
this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config));
|
|
81
|
+
control.signal.addEventListener("abort", () => {
|
|
82
|
+
this.dispose();
|
|
83
|
+
}, { once: true });
|
|
84
|
+
const bundledSkillDir = config.bundledSkillDir ?? (this.includeDefaultRoots ? process.env.DSH_BUNDLED_SKILL_DIR : void 0);
|
|
85
|
+
this.bundledSkillDir = bundledSkillDir === void 0 ? void 0 : resolve(bundledSkillDir);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Discover local skill summaries for a cwd-sensitive workspace.
|
|
89
|
+
* @param options - lookup options; `cwd` selects the project roots to scan.
|
|
90
|
+
* @returns local provider candidates with stable root ranks; watcher startup
|
|
91
|
+
* failure returns readable candidates as an incomplete observation.
|
|
92
|
+
*/
|
|
93
|
+
async list(options) {
|
|
94
|
+
const roots = await this.roots(options.cwd);
|
|
95
|
+
let complete = true;
|
|
96
|
+
try {
|
|
97
|
+
await this.watchManager.observeRoots(roots);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (this.disposal !== void 0) throw error;
|
|
100
|
+
complete = false;
|
|
101
|
+
}
|
|
102
|
+
const candidates = [];
|
|
103
|
+
for (const root of roots) for (const skill of await discoverRoot(root, this.ctx, this.name)) candidates.push(skill);
|
|
104
|
+
return complete ? candidates : {
|
|
105
|
+
candidates,
|
|
106
|
+
complete
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Load a complete local skill body from the candidate's file locator.
|
|
111
|
+
* @param candidate - the winning candidate returned by this provider.
|
|
112
|
+
* @param options - lookup options whose signal cancels filesystem reads.
|
|
113
|
+
* @returns the full local skill, or `undefined` if the file disappeared.
|
|
114
|
+
*/
|
|
115
|
+
async get(candidate, options) {
|
|
116
|
+
const locator = candidate.locator;
|
|
117
|
+
const parsed = await parseSkillFile(locator.path, this.ctx, options.signal, candidate.source === "bundled");
|
|
118
|
+
if (parsed === void 0) return void 0;
|
|
119
|
+
return {
|
|
120
|
+
name: parsed.name,
|
|
121
|
+
description: parsed.description,
|
|
122
|
+
...parsed.whenToUse !== void 0 ? { whenToUse: parsed.whenToUse } : {},
|
|
123
|
+
invocation: parsed.invocation,
|
|
124
|
+
source: candidate.source,
|
|
125
|
+
provider: this.name,
|
|
126
|
+
resourceBase: {
|
|
127
|
+
kind: "directory",
|
|
128
|
+
path: locator.directory
|
|
129
|
+
},
|
|
130
|
+
path: locator.path,
|
|
131
|
+
...parsed.metadata !== void 0 ? { metadata: parsed.metadata } : {},
|
|
132
|
+
content: parsed.content
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Invalidate this provider synchronously after a first-party filesystem mutation.
|
|
137
|
+
* @param path - host display path observed after a model-facing write or edit.
|
|
138
|
+
*/
|
|
139
|
+
observeHostMutation(path) {
|
|
140
|
+
this.watchManager.observeHostMutation(path);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Close every host watcher and contain late filesystem callbacks.
|
|
144
|
+
* @returns a shared promise that settles when every watcher reaches quiescence.
|
|
145
|
+
*/
|
|
146
|
+
dispose() {
|
|
147
|
+
this.disposal ??= this.watchManager.dispose();
|
|
148
|
+
return this.disposal;
|
|
149
|
+
}
|
|
150
|
+
async roots(cwd) {
|
|
151
|
+
const roots = [];
|
|
152
|
+
if (this.includeDefaultRoots && cwd !== void 0) {
|
|
153
|
+
const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx));
|
|
154
|
+
roots.push({
|
|
155
|
+
path: join(projectRoot, ".dsh/skills"),
|
|
156
|
+
source: "project-dsh",
|
|
157
|
+
rank: PROJECT_DSH_RANK,
|
|
158
|
+
projectRoot
|
|
159
|
+
}, {
|
|
160
|
+
path: join(projectRoot, ".agents/skills"),
|
|
161
|
+
source: "project-agents",
|
|
162
|
+
rank: PROJECT_AGENTS_RANK,
|
|
163
|
+
projectRoot
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
roots.push(...this.customSkillDirs.map((path) => ({
|
|
167
|
+
path,
|
|
168
|
+
source: "custom",
|
|
169
|
+
rank: CUSTOM_RANK
|
|
170
|
+
})));
|
|
171
|
+
if (this.includeDefaultRoots) roots.push({
|
|
172
|
+
path: join(this.dshHome, "skills"),
|
|
173
|
+
source: "user-dsh",
|
|
174
|
+
rank: USER_DSH_RANK,
|
|
175
|
+
skipSystem: true
|
|
176
|
+
}, {
|
|
177
|
+
path: join(this.agentsHome, "skills"),
|
|
178
|
+
source: "user-agents",
|
|
179
|
+
rank: USER_AGENTS_RANK
|
|
180
|
+
});
|
|
181
|
+
if (this.bundledSkillDir !== void 0) roots.push({
|
|
182
|
+
path: this.bundledSkillDir,
|
|
183
|
+
source: "bundled",
|
|
184
|
+
rank: BUNDLED_SKILL_RANK,
|
|
185
|
+
trustedHost: true
|
|
186
|
+
});
|
|
187
|
+
return roots;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
/** Owns bounded host watchers while discovery and reads remain on the filesystem service. */
|
|
191
|
+
var SkillWatchManager = class {
|
|
192
|
+
ctx;
|
|
193
|
+
invalidate;
|
|
194
|
+
config;
|
|
195
|
+
roots = /* @__PURE__ */ new Map();
|
|
196
|
+
projects = /* @__PURE__ */ new Map();
|
|
197
|
+
lifecycle = new AbortController();
|
|
198
|
+
closing = false;
|
|
199
|
+
invalidationQueued = false;
|
|
200
|
+
constructor(ctx, invalidate, config) {
|
|
201
|
+
this.ctx = ctx;
|
|
202
|
+
this.invalidate = invalidate;
|
|
203
|
+
this.config = config;
|
|
204
|
+
}
|
|
205
|
+
async observeRoots(roots) {
|
|
206
|
+
if (this.closing) return;
|
|
207
|
+
const projectRoots = /* @__PURE__ */ new Map();
|
|
208
|
+
const pending = [];
|
|
209
|
+
for (const root of roots) {
|
|
210
|
+
if (root.projectRoot === void 0) {
|
|
211
|
+
pending.push(this.retainRoot(root, `shared:${root.path}`));
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const grouped = projectRoots.get(root.projectRoot) ?? [];
|
|
215
|
+
grouped.push(root);
|
|
216
|
+
projectRoots.set(root.projectRoot, grouped);
|
|
217
|
+
}
|
|
218
|
+
for (const [projectRoot, grouped] of projectRoots) {
|
|
219
|
+
const owner = `project:${projectRoot}`;
|
|
220
|
+
this.projects.delete(projectRoot);
|
|
221
|
+
const paths = new Set(grouped.map((root) => root.path));
|
|
222
|
+
this.projects.set(projectRoot, paths);
|
|
223
|
+
for (const root of grouped) pending.push(this.retainRoot(root, owner));
|
|
224
|
+
}
|
|
225
|
+
let evictedProject = false;
|
|
226
|
+
while (this.projects.size > this.config.maxProjects) {
|
|
227
|
+
const oldest = this.projects.entries().next();
|
|
228
|
+
/* v8 ignore next -- the loop condition proves one project exists. */
|
|
229
|
+
if (oldest.done) break;
|
|
230
|
+
const [projectRoot, paths] = oldest.value;
|
|
231
|
+
this.projects.delete(projectRoot);
|
|
232
|
+
const owner = `project:${projectRoot}`;
|
|
233
|
+
for (const path of paths) pending.push(this.releaseRoot(path, owner));
|
|
234
|
+
evictedProject = true;
|
|
235
|
+
}
|
|
236
|
+
await Promise.all(pending);
|
|
237
|
+
if (evictedProject) this.invalidate();
|
|
238
|
+
}
|
|
239
|
+
observeHostMutation(path) {
|
|
240
|
+
if (this.closing) return;
|
|
241
|
+
const normalized = resolve(path);
|
|
242
|
+
if (![...this.roots.values()].some((state) => isPotentialSkillPath(state.root, normalized))) return;
|
|
243
|
+
this.invalidate();
|
|
244
|
+
}
|
|
245
|
+
async dispose() {
|
|
246
|
+
this.closing = true;
|
|
247
|
+
this.lifecycle.abort(/* @__PURE__ */ new Error("skill-filesystem watcher disposed"));
|
|
248
|
+
const states = [...this.roots.values()];
|
|
249
|
+
this.roots.clear();
|
|
250
|
+
this.projects.clear();
|
|
251
|
+
await Promise.all(states.map(async (state) => {
|
|
252
|
+
await settleWatcherOpening(state.opening);
|
|
253
|
+
const watcher = state.watcher;
|
|
254
|
+
state.watcher = void 0;
|
|
255
|
+
if (watcher !== void 0) await this.closeWatcher(watcher);
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
async retainRoot(root, owner) {
|
|
259
|
+
let state = this.roots.get(root.path);
|
|
260
|
+
if (state === void 0) {
|
|
261
|
+
state = {
|
|
262
|
+
root,
|
|
263
|
+
owners: /* @__PURE__ */ new Set(),
|
|
264
|
+
watcher: void 0,
|
|
265
|
+
opening: void 0,
|
|
266
|
+
unhealthy: true
|
|
267
|
+
};
|
|
268
|
+
this.roots.set(root.path, state);
|
|
269
|
+
}
|
|
270
|
+
state.owners.add(owner);
|
|
271
|
+
if (this.config.enabled) await this.ensureWatcher(state);
|
|
272
|
+
}
|
|
273
|
+
async releaseRoot(path, owner) {
|
|
274
|
+
const state = this.roots.get(path);
|
|
275
|
+
/* v8 ignore next -- Concurrent cwd observations can evict the same shared root before this release settles. */
|
|
276
|
+
if (state === void 0) return;
|
|
277
|
+
state.owners.delete(owner);
|
|
278
|
+
if (state.owners.size > 0) return;
|
|
279
|
+
this.roots.delete(path);
|
|
280
|
+
await settleWatcherOpening(state.opening);
|
|
281
|
+
const watcher = state.watcher;
|
|
282
|
+
state.watcher = void 0;
|
|
283
|
+
if (watcher !== void 0) await this.closeWatcher(watcher);
|
|
284
|
+
}
|
|
285
|
+
ensureWatcher(state) {
|
|
286
|
+
/* v8 ignore next -- A scheduled rewatch can reach this guard only when teardown wins its await. */
|
|
287
|
+
if (this.closing || !this.config.enabled) return Promise.resolve();
|
|
288
|
+
if (state.opening !== void 0) return state.opening;
|
|
289
|
+
const opening = this.ensureCurrentWatcher(state);
|
|
290
|
+
state.opening = opening;
|
|
291
|
+
opening.then(() => {
|
|
292
|
+
state.opening = void 0;
|
|
293
|
+
}, () => {
|
|
294
|
+
state.opening = void 0;
|
|
295
|
+
});
|
|
296
|
+
return opening;
|
|
297
|
+
}
|
|
298
|
+
async ensureCurrentWatcher(state) {
|
|
299
|
+
const watcher = state.watcher;
|
|
300
|
+
if (watcher !== void 0 && !state.unhealthy) {
|
|
301
|
+
const current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks);
|
|
302
|
+
if (!state.unhealthy && sameWatchMode(watcher.mode, current)) return;
|
|
303
|
+
}
|
|
304
|
+
await this.replaceWatcher(state);
|
|
305
|
+
}
|
|
306
|
+
async replaceWatcher(state) {
|
|
307
|
+
const previous = state.watcher;
|
|
308
|
+
state.watcher = void 0;
|
|
309
|
+
if (previous !== void 0) await this.closeWatcher(previous);
|
|
310
|
+
/* v8 ignore next -- Teardown can win while an unhealthy watcher is still closing. */
|
|
311
|
+
if (this.closing || state.owners.size === 0) return;
|
|
312
|
+
try {
|
|
313
|
+
const watcher = await this.openStableWatcher(state);
|
|
314
|
+
/* v8 ignore next -- The loop returns no handle only when teardown wins between awaited probes. */
|
|
315
|
+
if (watcher === void 0) return;
|
|
316
|
+
/* v8 ignore start -- Post-open teardown is timing-dependent; the disposal race has an explicit integration test. */
|
|
317
|
+
if (this.closing || state.owners.size === 0) {
|
|
318
|
+
await this.closeWatcher(watcher);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
/* v8 ignore stop */
|
|
322
|
+
state.watcher = watcher;
|
|
323
|
+
state.unhealthy = false;
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (!this.closing) {
|
|
326
|
+
state.unhealthy = true;
|
|
327
|
+
this.ctx.logger.warn(`skill-filesystem: failed to watch ${state.root.path}: ${errorMessage(error)}`);
|
|
328
|
+
}
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async openStableWatcher(state) {
|
|
333
|
+
while (!this.closing && state.owners.size > 0) {
|
|
334
|
+
const mode = await resolveRootWatchMode(state.root.path, this.config.followSymlinks);
|
|
335
|
+
const watcher = mode.kind === "ancestor" ? this.openAncestorWatcher(state, mode) : await this.openRootWatcher(state, mode);
|
|
336
|
+
/* v8 ignore else -- A host path transition between the two probes is timing-dependent. */
|
|
337
|
+
if (sameWatchMode(mode, await resolveRootWatchMode(state.root.path, this.config.followSymlinks))) return watcher;
|
|
338
|
+
/* v8 ignore next -- Covered by the same host path transition guard. */
|
|
339
|
+
await this.closeWatcher(watcher);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
openAncestorWatcher(state, mode) {
|
|
343
|
+
const listener = (_current, _previous) => {
|
|
344
|
+
this.handleAncestorWatchEvent(state, mode);
|
|
345
|
+
};
|
|
346
|
+
watchFile(mode.nextPath, {
|
|
347
|
+
persistent: false,
|
|
348
|
+
interval: this.config.pollIntervalMs
|
|
349
|
+
}, listener);
|
|
350
|
+
return {
|
|
351
|
+
mode,
|
|
352
|
+
close() {
|
|
353
|
+
unwatchFile(mode.nextPath, listener);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
async handleAncestorWatchEvent(state, mode) {
|
|
358
|
+
let current;
|
|
359
|
+
try {
|
|
360
|
+
current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
/* v8 ignore start -- Non-absence stat failures need a platform permission or I/O fault. */
|
|
363
|
+
if (!this.closing && state.owners.size > 0) this.handleWatcherError(state, error);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (this.closing || state.owners.size === 0 || sameWatchMode(mode, current)) return;
|
|
367
|
+
this.queueInvalidation();
|
|
368
|
+
state.unhealthy = true;
|
|
369
|
+
this.scheduleRewatch(state);
|
|
370
|
+
}
|
|
371
|
+
async openRootWatcher(state, mode) {
|
|
372
|
+
const watcher = chokidar.watch(mode.anchor, {
|
|
373
|
+
persistent: true,
|
|
374
|
+
ignoreInitial: true,
|
|
375
|
+
depth: 1,
|
|
376
|
+
followSymlinks: this.config.followSymlinks,
|
|
377
|
+
atomic: true,
|
|
378
|
+
awaitWriteFinish: {
|
|
379
|
+
stabilityThreshold: this.config.stabilityThresholdMs,
|
|
380
|
+
pollInterval: this.config.pollIntervalMs
|
|
381
|
+
},
|
|
382
|
+
usePolling: this.config.usePolling,
|
|
383
|
+
interval: this.config.pollIntervalMs
|
|
384
|
+
});
|
|
385
|
+
const handle = {
|
|
386
|
+
mode,
|
|
387
|
+
close: () => watcher.close()
|
|
388
|
+
};
|
|
389
|
+
let ready = false;
|
|
390
|
+
const readiness = Promise.withResolvers();
|
|
391
|
+
const signal = this.lifecycle.signal;
|
|
392
|
+
if (signal.aborted) {
|
|
393
|
+
await this.closeWatcher(handle);
|
|
394
|
+
signal.throwIfAborted();
|
|
395
|
+
}
|
|
396
|
+
const onAbort = () => {
|
|
397
|
+
readiness.reject(signal.reason);
|
|
398
|
+
};
|
|
399
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
400
|
+
const onError = (error) => {
|
|
401
|
+
if (!ready) {
|
|
402
|
+
readiness.reject(error);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
this.handleWatcherError(state, error);
|
|
406
|
+
};
|
|
407
|
+
watcher.on("error", onError);
|
|
408
|
+
watcher.once("ready", () => {
|
|
409
|
+
ready = true;
|
|
410
|
+
readiness.resolve(void 0);
|
|
411
|
+
});
|
|
412
|
+
for (const event of [
|
|
413
|
+
"add",
|
|
414
|
+
"addDir",
|
|
415
|
+
"change",
|
|
416
|
+
"unlink",
|
|
417
|
+
"unlinkDir"
|
|
418
|
+
]) watcher.on(event, (path) => {
|
|
419
|
+
this.handleWatchEvent(state, mode, event, path);
|
|
420
|
+
});
|
|
421
|
+
try {
|
|
422
|
+
await readiness.promise;
|
|
423
|
+
} catch (error) {
|
|
424
|
+
await this.closeWatcher(handle);
|
|
425
|
+
throw error;
|
|
426
|
+
} finally {
|
|
427
|
+
signal.removeEventListener("abort", onAbort);
|
|
428
|
+
}
|
|
429
|
+
return handle;
|
|
430
|
+
}
|
|
431
|
+
handleWatchEvent(state, mode, event, path) {
|
|
432
|
+
const target = resolve(path);
|
|
433
|
+
if (this.closing || !isRelevantWatchEvent({
|
|
434
|
+
...state.root,
|
|
435
|
+
path: mode.anchor
|
|
436
|
+
}, event, target)) return;
|
|
437
|
+
this.queueInvalidation();
|
|
438
|
+
if (target === mode.anchor && event === "unlinkDir") {
|
|
439
|
+
state.unhealthy = true;
|
|
440
|
+
this.scheduleRewatch(state);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
handleWatcherError(state, error) {
|
|
444
|
+
if (this.closing) return;
|
|
445
|
+
this.ctx.logger.warn(`skill-filesystem: watcher for ${state.root.path} failed: ${errorMessage(error)}`);
|
|
446
|
+
state.unhealthy = true;
|
|
447
|
+
this.queueInvalidation();
|
|
448
|
+
this.scheduleRewatch(state);
|
|
449
|
+
}
|
|
450
|
+
scheduleRewatch(state) {
|
|
451
|
+
const currentOpening = state.opening ?? Promise.resolve();
|
|
452
|
+
(async () => {
|
|
453
|
+
await settleWatcherOpening(currentOpening);
|
|
454
|
+
try {
|
|
455
|
+
await this.ensureWatcher(state);
|
|
456
|
+
} catch {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
this.queueInvalidation();
|
|
460
|
+
})();
|
|
461
|
+
}
|
|
462
|
+
queueInvalidation() {
|
|
463
|
+
if (this.closing || this.invalidationQueued) return;
|
|
464
|
+
this.invalidationQueued = true;
|
|
465
|
+
queueMicrotask(() => {
|
|
466
|
+
this.invalidationQueued = false;
|
|
467
|
+
/* v8 ignore next -- Effect teardown can win this queued microtask before provider disposal emits. */
|
|
468
|
+
if (this.closing) return;
|
|
469
|
+
this.invalidate();
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
async closeWatcher(watcher) {
|
|
473
|
+
try {
|
|
474
|
+
await watcher.close();
|
|
475
|
+
} catch (error) {
|
|
476
|
+
this.ctx.logger.warn(`skill-filesystem: failed to close watcher: ${errorMessage(error)}`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
async function settleWatcherOpening(opening) {
|
|
481
|
+
if (opening === void 0) return;
|
|
482
|
+
try {
|
|
483
|
+
await opening;
|
|
484
|
+
} catch {}
|
|
485
|
+
}
|
|
486
|
+
function resolveWatchConfig(config) {
|
|
487
|
+
const stabilityThresholdMs = config.watchStabilityThresholdMs ?? DEFAULT_WATCH_STABILITY_THRESHOLD_MS;
|
|
488
|
+
const pollIntervalMs = config.watchPollIntervalMs ?? DEFAULT_WATCH_POLL_INTERVAL_MS;
|
|
489
|
+
const maxProjects = config.watchMaxProjects ?? DEFAULT_WATCH_MAX_PROJECTS;
|
|
490
|
+
assertPositiveInteger("watchStabilityThresholdMs", stabilityThresholdMs);
|
|
491
|
+
assertPositiveInteger("watchPollIntervalMs", pollIntervalMs);
|
|
492
|
+
assertPositiveInteger("watchMaxProjects", maxProjects);
|
|
493
|
+
return {
|
|
494
|
+
enabled: config.watch ?? true,
|
|
495
|
+
usePolling: config.watchUsePolling ?? false,
|
|
496
|
+
stabilityThresholdMs,
|
|
497
|
+
pollIntervalMs,
|
|
498
|
+
maxProjects,
|
|
499
|
+
followSymlinks: config.watchFollowSymlinks ?? true
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
async function resolveRootWatchMode(root, followSymlinks) {
|
|
503
|
+
let candidate = root;
|
|
504
|
+
while (true) {
|
|
505
|
+
try {
|
|
506
|
+
if ((await stat(candidate)).isDirectory()) {
|
|
507
|
+
const anchor = candidate === root && !followSymlinks && (await lstat(candidate)).isSymbolicLink() ? resolve(candidate) : await canonicalizeWatchPath(candidate);
|
|
508
|
+
if (candidate === root) return {
|
|
509
|
+
kind: "root",
|
|
510
|
+
anchor
|
|
511
|
+
};
|
|
512
|
+
const firstSegment = relative(candidate, root).split(sep)[0];
|
|
513
|
+
/* v8 ignore next -- candidate is a strict ancestor of root. */
|
|
514
|
+
if (firstSegment === void 0 || firstSegment.length === 0) return {
|
|
515
|
+
kind: "root",
|
|
516
|
+
anchor
|
|
517
|
+
};
|
|
518
|
+
return {
|
|
519
|
+
kind: "ancestor",
|
|
520
|
+
anchor,
|
|
521
|
+
nextPath: join(anchor, firstSegment)
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
} catch (error) {
|
|
525
|
+
/* v8 ignore next -- Non-absence stat failures are platform/permission-specific and propagate as incomplete discovery. */
|
|
526
|
+
if (!isAbsentPathError(error)) throw error;
|
|
527
|
+
}
|
|
528
|
+
const parent = dirname(candidate);
|
|
529
|
+
/* v8 ignore next -- Traversal reaches the existing filesystem root before this fallback. */
|
|
530
|
+
if (parent === candidate) return {
|
|
531
|
+
kind: "ancestor",
|
|
532
|
+
anchor: candidate,
|
|
533
|
+
nextPath: root
|
|
534
|
+
};
|
|
535
|
+
candidate = parent;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
function sameWatchMode(left, right) {
|
|
539
|
+
return left.kind === right.kind && left.anchor === right.anchor && (left.kind === "root" || right.kind === "ancestor" && left.nextPath === right.nextPath);
|
|
540
|
+
}
|
|
541
|
+
function isRelevantWatchEvent(root, event, path) {
|
|
542
|
+
const segments = containedSegments(root.path, path);
|
|
543
|
+
if (segments === void 0) return false;
|
|
544
|
+
if (segments.length === 0) return event === "addDir" || event === "unlinkDir";
|
|
545
|
+
if (root.skipSystem === true && segments[0] === ".system") return false;
|
|
546
|
+
if (segments.length === 1) {
|
|
547
|
+
if (event === "addDir" || event === "unlinkDir") return true;
|
|
548
|
+
return segments[0]?.endsWith(".md") === true;
|
|
549
|
+
}
|
|
550
|
+
return segments.length === 2 && segments[1] === "SKILL.md" && event !== "addDir" && event !== "unlinkDir";
|
|
551
|
+
}
|
|
552
|
+
function isPotentialSkillPath(root, path) {
|
|
553
|
+
const segments = containedSegments(root.path, path);
|
|
554
|
+
if (segments === void 0 || segments.length === 0 || segments.length > 2) return false;
|
|
555
|
+
if (root.skipSystem === true && segments[0] === ".system") return false;
|
|
556
|
+
return segments.length === 1 ? segments[0]?.endsWith(".md") === true : segments[1] === "SKILL.md";
|
|
557
|
+
}
|
|
558
|
+
function containedSegments(root, path) {
|
|
559
|
+
const child = relative(root, path);
|
|
560
|
+
if (child.length === 0) return [];
|
|
561
|
+
if (child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)) return void 0;
|
|
562
|
+
return child.split(sep);
|
|
563
|
+
}
|
|
564
|
+
function mutationToolName(actor) {
|
|
565
|
+
if (actor === void 0 || !("name" in actor)) return void 0;
|
|
566
|
+
const value = actor.name;
|
|
567
|
+
return value === "edit" || value === "write" ? value : void 0;
|
|
568
|
+
}
|
|
569
|
+
function assertPositiveInteger(field, value) {
|
|
570
|
+
if (!Number.isInteger(value) || value < 1) throw new TypeError(`skill-filesystem: ${field} must be a positive integer`);
|
|
571
|
+
}
|
|
572
|
+
function isAbsentPathError(error) {
|
|
573
|
+
return hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR");
|
|
574
|
+
}
|
|
575
|
+
function isAbsentSkillPathError(error) {
|
|
576
|
+
return isAbsentPathError(error) || hasErrorCode(error, "FS_NOT_FOUND") || hasErrorCode(error, "FS_NOT_DIRECTORY");
|
|
577
|
+
}
|
|
578
|
+
function hasErrorCode(error, code) {
|
|
579
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
580
|
+
}
|
|
581
|
+
async function discoverRoot(root, ctx, provider) {
|
|
582
|
+
const skills = [];
|
|
583
|
+
const entries = await listSkillRootEntries(root, ctx);
|
|
584
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
585
|
+
if (root.skipSystem && entry.name === ".system") continue;
|
|
586
|
+
const locator = entry.type === "directory" ? {
|
|
587
|
+
path: join(entry.path, "SKILL.md"),
|
|
588
|
+
directory: entry.path
|
|
589
|
+
} : entry.type === "file" && entry.name.endsWith(".md") ? {
|
|
590
|
+
path: entry.path,
|
|
591
|
+
directory: root.path
|
|
592
|
+
} : void 0;
|
|
593
|
+
if (locator === void 0) continue;
|
|
594
|
+
const parsed = await parseSkillFile(locator.path, ctx, void 0, root.trustedHost === true);
|
|
595
|
+
if (parsed === void 0) continue;
|
|
596
|
+
skills.push({
|
|
597
|
+
name: parsed.name,
|
|
598
|
+
description: parsed.description,
|
|
599
|
+
...parsed.whenToUse !== void 0 ? { whenToUse: parsed.whenToUse } : {},
|
|
600
|
+
invocation: parsed.invocation,
|
|
601
|
+
provider,
|
|
602
|
+
source: root.source,
|
|
603
|
+
rank: root.rank,
|
|
604
|
+
locator,
|
|
605
|
+
resourceBase: {
|
|
606
|
+
kind: "directory",
|
|
607
|
+
path: locator.directory
|
|
608
|
+
},
|
|
609
|
+
path: locator.path,
|
|
610
|
+
...parsed.metadata !== void 0 ? { metadata: parsed.metadata } : {}
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
return skills;
|
|
614
|
+
}
|
|
615
|
+
async function listSkillRootEntries(root, ctx) {
|
|
616
|
+
const fs = optionalFileSystem(ctx);
|
|
617
|
+
if (fs !== void 0 && root.trustedHost !== true) return await listSkillRootEntriesFromFileSystem(root, fs);
|
|
618
|
+
return await listSkillRootEntriesFromNode(root, ctx);
|
|
619
|
+
}
|
|
620
|
+
async function listSkillRootEntriesFromFileSystem(root, fs) {
|
|
621
|
+
try {
|
|
622
|
+
return (await fsListDir(fs, root.path)).map(entryFromFs);
|
|
623
|
+
} catch (error) {
|
|
624
|
+
if (isAbsentSkillPathError(error)) return [];
|
|
625
|
+
throw error;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
async function fsListDir(fs, path) {
|
|
629
|
+
const target = await fs.resolve(path);
|
|
630
|
+
return await fs.listDir(target);
|
|
631
|
+
}
|
|
632
|
+
function entryFromFs(entry) {
|
|
633
|
+
return {
|
|
634
|
+
name: entry.name,
|
|
635
|
+
type: entry.type,
|
|
636
|
+
path: entry.target.displayPath
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
async function listSkillRootEntriesFromNode(root, ctx) {
|
|
640
|
+
let entries;
|
|
641
|
+
try {
|
|
642
|
+
entries = await readdir(root.path, {
|
|
643
|
+
withFileTypes: true,
|
|
644
|
+
encoding: "utf8"
|
|
645
|
+
});
|
|
646
|
+
} catch (error) {
|
|
647
|
+
/* v8 ignore else -- Native non-absence directory failures are provider-dependent; the ctx.fs path pins incomplete discovery. */
|
|
648
|
+
if (isAbsentSkillPathError(error)) return [];
|
|
649
|
+
/* v8 ignore next -- Same native error branch as above. */
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
const result = [];
|
|
653
|
+
for (const entry of entries) {
|
|
654
|
+
const path = join(root.path, entry.name);
|
|
655
|
+
const type = await nodeEntryKind(path, entry, ctx);
|
|
656
|
+
result.push({
|
|
657
|
+
name: entry.name,
|
|
658
|
+
type: type ?? "other",
|
|
659
|
+
path
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
return result;
|
|
663
|
+
}
|
|
664
|
+
async function parseSkillFile(path, ctx, signal, trustedHost = false) {
|
|
665
|
+
const raw = await readSkillText(ctx, path, signal, trustedHost);
|
|
666
|
+
signal?.throwIfAborted();
|
|
667
|
+
if (raw === void 0) return;
|
|
668
|
+
let parsed;
|
|
669
|
+
try {
|
|
670
|
+
parsed = parseFrontmatter(raw);
|
|
671
|
+
} catch (error) {
|
|
672
|
+
ctx.logger.warn(`skill file ${path} ignored: invalid YAML frontmatter: ${errorMessage(error)}`);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (!parsed) {
|
|
676
|
+
ctx.logger.warn(`skill file ${path} ignored: missing YAML frontmatter`);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const name = stringField(parsed.data, "name");
|
|
680
|
+
const description = stringField(parsed.data, "description");
|
|
681
|
+
if (name === void 0 || description === void 0) {
|
|
682
|
+
ctx.logger.warn(`skill file ${path} ignored: frontmatter requires name and description`);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (!isSkillName(name)) {
|
|
686
|
+
ctx.logger.warn(`skill file ${path} ignored: invalid skill name "${name}"`);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
let invocation;
|
|
690
|
+
try {
|
|
691
|
+
invocation = parseInvocationPolicy(parsed.data);
|
|
692
|
+
} catch (error) {
|
|
693
|
+
ctx.logger.warn(`skill file ${path} ignored: invalid invocation frontmatter: ${errorMessage(error)}`);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
name,
|
|
698
|
+
description,
|
|
699
|
+
...optionalString(parsed.data, "whenToUse"),
|
|
700
|
+
invocation,
|
|
701
|
+
...optionalMetadata(parsed.data),
|
|
702
|
+
content: parsed.body.trim()
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
function optionalFileSystem(ctx) {
|
|
706
|
+
return ctx.get("fs");
|
|
707
|
+
}
|
|
708
|
+
async function readSkillText(ctx, path, signal, trustedHost = false) {
|
|
709
|
+
signal?.throwIfAborted();
|
|
710
|
+
const fs = optionalFileSystem(ctx);
|
|
711
|
+
if (fs !== void 0 && !trustedHost) return await readSkillTextFromFileSystem(ctx, fs, path, signal);
|
|
712
|
+
try {
|
|
713
|
+
return await readFile(path, {
|
|
714
|
+
encoding: "utf8",
|
|
715
|
+
signal
|
|
716
|
+
});
|
|
717
|
+
} catch (error) {
|
|
718
|
+
signal?.throwIfAborted();
|
|
719
|
+
if (isAbsentSkillPathError(error)) return void 0;
|
|
720
|
+
throw error;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
async function readSkillTextFromFileSystem(ctx, fs, path, signal) {
|
|
724
|
+
signal?.throwIfAborted();
|
|
725
|
+
let target;
|
|
726
|
+
try {
|
|
727
|
+
target = await fs.resolve(path);
|
|
728
|
+
} catch (error) {
|
|
729
|
+
if (isAbsentSkillPathError(error)) return void 0;
|
|
730
|
+
throw error;
|
|
731
|
+
}
|
|
732
|
+
signal?.throwIfAborted();
|
|
733
|
+
let info;
|
|
734
|
+
try {
|
|
735
|
+
info = await fs.stat(target, signal);
|
|
736
|
+
} catch (error) {
|
|
737
|
+
signal?.throwIfAborted();
|
|
738
|
+
if (isAbsentSkillPathError(error)) return void 0;
|
|
739
|
+
throw error;
|
|
740
|
+
}
|
|
741
|
+
if (info === void 0 || info.type !== "file") return void 0;
|
|
742
|
+
try {
|
|
743
|
+
return await fs.readText(target, signal);
|
|
744
|
+
} catch (error) {
|
|
745
|
+
signal?.throwIfAborted();
|
|
746
|
+
if (isAbsentSkillPathError(error)) return void 0;
|
|
747
|
+
if (!hasErrorCode(error, "FS_NOT_TEXT")) throw error;
|
|
748
|
+
ctx.logger.warn(`skill file ${path} ignored: ${fsReadErrorMessage(target, error)}`);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
function fsReadErrorMessage(target, error) {
|
|
753
|
+
return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`;
|
|
754
|
+
}
|
|
755
|
+
async function nodeEntryKind(fullPath, entry, ctx) {
|
|
756
|
+
if (entry.isDirectory()) return "directory";
|
|
757
|
+
if (entry.isFile()) return "file";
|
|
758
|
+
/* v8 ignore next -- Non-file directory entries such as FIFOs are platform-specific and intentionally skipped. */
|
|
759
|
+
if (!entry.isSymbolicLink()) return void 0;
|
|
760
|
+
try {
|
|
761
|
+
const info = await stat(fullPath);
|
|
762
|
+
if (info.isDirectory()) return "directory";
|
|
763
|
+
/* v8 ignore else -- the special-file symlink branch relies on POSIX /dev/null. */
|
|
764
|
+
if (info.isFile()) return "file";
|
|
765
|
+
/* v8 ignore next -- The special-file symlink fixture relies on POSIX /dev/null. */
|
|
766
|
+
return;
|
|
767
|
+
} catch (error) {
|
|
768
|
+
ctx.logger.warn(`skill entry ${fullPath} ignored: failed to follow symbolic link: ${errorMessage(error)}`);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
function parseFrontmatter(raw) {
|
|
773
|
+
const firstLineEnd = raw.indexOf("\n");
|
|
774
|
+
if (firstLineEnd < 0) return void 0;
|
|
775
|
+
if (raw.slice(0, firstLineEnd).replace(/\r$/, "") !== "---") return void 0;
|
|
776
|
+
const start = firstLineEnd + 1;
|
|
777
|
+
const closing = findClosingFrontmatter(raw, start);
|
|
778
|
+
if (closing === void 0) return void 0;
|
|
779
|
+
const parsed = parse(raw.slice(start, closing.start));
|
|
780
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
781
|
+
return {
|
|
782
|
+
data: parsed,
|
|
783
|
+
body: raw.slice(closing.bodyStart)
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
function findClosingFrontmatter(raw, start) {
|
|
787
|
+
let lineStart = start;
|
|
788
|
+
while (lineStart <= raw.length) {
|
|
789
|
+
const nextNewline = raw.indexOf("\n", lineStart);
|
|
790
|
+
const lineEnd = nextNewline < 0 ? raw.length : nextNewline;
|
|
791
|
+
if (raw.slice(lineStart, lineEnd).replace(/\r$/, "") === "---") return {
|
|
792
|
+
start: lineStart,
|
|
793
|
+
bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1
|
|
794
|
+
};
|
|
795
|
+
if (nextNewline < 0) return void 0;
|
|
796
|
+
lineStart = nextNewline + 1;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
async function findProjectRoot(cwd, fs) {
|
|
800
|
+
let current = cwd;
|
|
801
|
+
while (true) {
|
|
802
|
+
if (await pathExists(join(current, ".git"), fs)) return current;
|
|
803
|
+
const parent = dirname(current);
|
|
804
|
+
if (parent === current) return cwd;
|
|
805
|
+
current = parent;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
async function pathExists(path, fs) {
|
|
809
|
+
if (fs !== void 0) return await pathExistsInFileSystem(path, fs);
|
|
810
|
+
return await pathExistsInNode(path);
|
|
811
|
+
}
|
|
812
|
+
async function pathExistsInFileSystem(path, fs) {
|
|
813
|
+
let target;
|
|
814
|
+
try {
|
|
815
|
+
target = await fs.resolve(path);
|
|
816
|
+
} catch {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
try {
|
|
820
|
+
return await fs.stat(target) !== void 0;
|
|
821
|
+
} catch {
|
|
822
|
+
return false;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
async function pathExistsInNode(path) {
|
|
826
|
+
try {
|
|
827
|
+
await access(path);
|
|
828
|
+
return true;
|
|
829
|
+
} catch {
|
|
830
|
+
return false;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
function stringField(data, key) {
|
|
834
|
+
const value = data[key];
|
|
835
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
836
|
+
}
|
|
837
|
+
function optionalString(data, key) {
|
|
838
|
+
const value = data[key];
|
|
839
|
+
return typeof value === "string" && value.length > 0 ? { [key]: value } : {};
|
|
840
|
+
}
|
|
841
|
+
function parseInvocationPolicy(data) {
|
|
842
|
+
rejectLegacyInvocationKey(data, "disableModelInvocation", "disable-model-invocation");
|
|
843
|
+
rejectLegacyInvocationKey(data, "modelInvocable", "disable-model-invocation");
|
|
844
|
+
rejectLegacyInvocationKey(data, "userInvocable", "user-invocable");
|
|
845
|
+
const disableModelInvocation = frontmatterBoolean(data, "disable-model-invocation");
|
|
846
|
+
const userInvocable = frontmatterBoolean(data, "user-invocable");
|
|
847
|
+
return {
|
|
848
|
+
modelInvocable: disableModelInvocation !== true,
|
|
849
|
+
userInvocable: userInvocable !== false
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
function rejectLegacyInvocationKey(data, legacy, canonical) {
|
|
853
|
+
if (Object.hasOwn(data, legacy)) throw new Error(`frontmatter field "${legacy}" is unsupported; use "${canonical}"`);
|
|
854
|
+
}
|
|
855
|
+
function frontmatterBoolean(data, key) {
|
|
856
|
+
if (!Object.hasOwn(data, key)) return void 0;
|
|
857
|
+
const value = data[key];
|
|
858
|
+
if (typeof value === "boolean") return value;
|
|
859
|
+
if (value === 1 || value === "1") return true;
|
|
860
|
+
if (value === 0 || value === "0") return false;
|
|
861
|
+
if (typeof value === "string") switch (value.toLowerCase()) {
|
|
862
|
+
case "true":
|
|
863
|
+
case "yes":
|
|
864
|
+
case "on": return true;
|
|
865
|
+
case "false":
|
|
866
|
+
case "no":
|
|
867
|
+
case "off": return false;
|
|
868
|
+
}
|
|
869
|
+
throw new TypeError(`frontmatter field "${key}" must be a boolean`);
|
|
870
|
+
}
|
|
871
|
+
function optionalMetadata(data) {
|
|
872
|
+
const value = data.metadata;
|
|
873
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) return { metadata: value };
|
|
874
|
+
return {};
|
|
875
|
+
}
|
|
876
|
+
function errorMessage(error) {
|
|
877
|
+
return String(error);
|
|
878
|
+
}
|
|
879
|
+
//#endregion
|
|
880
|
+
export { Config, FileSystemSkillProvider, apply, inject, name };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-skill-filesystem`.
|
|
4
|
+
* @module @deepseek-ai/dsh-skill-filesystem/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@deepseek-ai/dsh-skill-filesystem";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "skill-filesystem-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
|
13
|
+
* beyond contracts enforced at its owning seam.
|
|
14
|
+
*/
|
|
15
|
+
const install = () => {};
|
|
16
|
+
/**
|
|
17
|
+
* Register this package's invariant companion.
|
|
18
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
19
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
20
|
+
*/
|
|
21
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
22
|
+
//#endregion
|
|
23
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local filesystem skill provider.
|
|
3
|
+
*
|
|
4
|
+
* This package is one implementation of the `ctx.skills` provider registry. It
|
|
5
|
+
* discovers directory-bundle and flat Markdown skills from project, custom, and
|
|
6
|
+
* user roots, parses YAML frontmatter, and loads bodies through `ctx.fs` when a
|
|
7
|
+
* filesystem service is present.
|
|
8
|
+
*
|
|
9
|
+
* @module @deepseek-ai/dsh-skill-filesystem
|
|
10
|
+
*/
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
12
|
+
import type Schema from '@deepseek-ai/schemastery';
|
|
13
|
+
import { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider, type SkillProviderControl, type SkillProviderObservation } from '@deepseek-ai/dsh-skill';
|
|
14
|
+
export declare const name = "skill-filesystem";
|
|
15
|
+
export declare const inject: string[];
|
|
16
|
+
/** Local filesystem skill provider configuration. */
|
|
17
|
+
export interface Config {
|
|
18
|
+
/** Unique provider name. Defaults to `local`. */
|
|
19
|
+
providerName?: string;
|
|
20
|
+
/** Whether project and user roots are included around custom roots. */
|
|
21
|
+
includeDefaultRoots?: boolean;
|
|
22
|
+
/** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
|
|
23
|
+
dshHome?: string;
|
|
24
|
+
/** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */
|
|
25
|
+
agentsHome?: string;
|
|
26
|
+
/** Additional skill roots scanned after project roots and before user roots. */
|
|
27
|
+
customSkillDirs?: string[];
|
|
28
|
+
/** Whether host-local skill roots are watched for catalog changes. */
|
|
29
|
+
watch?: boolean;
|
|
30
|
+
/** Whether Chokidar uses polling instead of native filesystem events. */
|
|
31
|
+
watchUsePolling?: boolean;
|
|
32
|
+
/** Milliseconds a changed skill entry must remain stable before it is observed. */
|
|
33
|
+
watchStabilityThresholdMs?: number;
|
|
34
|
+
/** Milliseconds between Chokidar stability or polling probes. */
|
|
35
|
+
watchPollIntervalMs?: number;
|
|
36
|
+
/** Maximum distinct project roots whose skill directories remain watched. */
|
|
37
|
+
watchMaxProjects?: number;
|
|
38
|
+
/** Whether watched symbolic links follow their target files. */
|
|
39
|
+
watchFollowSymlinks?: boolean;
|
|
40
|
+
/** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR` when default roots are included, otherwise mounts none. */
|
|
41
|
+
bundledSkillDir?: string;
|
|
42
|
+
}
|
|
43
|
+
export declare const Config: Schema<Config>;
|
|
44
|
+
/** Register the local filesystem skill provider on `ctx.skills`. */
|
|
45
|
+
export declare function apply(ctx: Context, config?: Config): void;
|
|
46
|
+
/** Provider that maps local project/user skill roots into `ctx.skills`. */
|
|
47
|
+
export declare class FileSystemSkillProvider implements SkillProvider {
|
|
48
|
+
private readonly ctx;
|
|
49
|
+
readonly name: string;
|
|
50
|
+
private readonly includeDefaultRoots;
|
|
51
|
+
private readonly dshHome;
|
|
52
|
+
private readonly agentsHome;
|
|
53
|
+
private readonly customSkillDirs;
|
|
54
|
+
private readonly watchManager;
|
|
55
|
+
private readonly bundledSkillDir;
|
|
56
|
+
private disposal;
|
|
57
|
+
constructor(ctx: Context, control: SkillProviderControl, config?: Config);
|
|
58
|
+
/**
|
|
59
|
+
* Discover local skill summaries for a cwd-sensitive workspace.
|
|
60
|
+
* @param options - lookup options; `cwd` selects the project roots to scan.
|
|
61
|
+
* @returns local provider candidates with stable root ranks; watcher startup
|
|
62
|
+
* failure returns readable candidates as an incomplete observation.
|
|
63
|
+
*/
|
|
64
|
+
list(options: SkillLookupOptions): Promise<SkillCandidate[] | SkillProviderObservation>;
|
|
65
|
+
/**
|
|
66
|
+
* Load a complete local skill body from the candidate's file locator.
|
|
67
|
+
* @param candidate - the winning candidate returned by this provider.
|
|
68
|
+
* @param options - lookup options whose signal cancels filesystem reads.
|
|
69
|
+
* @returns the full local skill, or `undefined` if the file disappeared.
|
|
70
|
+
*/
|
|
71
|
+
get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined>;
|
|
72
|
+
/**
|
|
73
|
+
* Invalidate this provider synchronously after a first-party filesystem mutation.
|
|
74
|
+
* @param path - host display path observed after a model-facing write or edit.
|
|
75
|
+
*/
|
|
76
|
+
observeHostMutation(path: string): void;
|
|
77
|
+
/**
|
|
78
|
+
* Close every host watcher and contain late filesystem callbacks.
|
|
79
|
+
* @returns a shared promise that settles when every watcher reaches quiescence.
|
|
80
|
+
*/
|
|
81
|
+
dispose(): Promise<void>;
|
|
82
|
+
private roots;
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-skill-filesystem`.
|
|
3
|
+
* @module @deepseek-ai/dsh-skill-filesystem/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "skill-filesystem-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deepseek-ai/dsh-skill-filesystem",
|
|
3
|
+
"description": "Local filesystem skill provider for the DeepSeek Harness",
|
|
4
|
+
"version": "0.0.1-rc.3",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "restricted"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
|
11
|
+
"directory": "packages/skill/skill-filesystem"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./invariant": {
|
|
22
|
+
"types": "./lib/types/invariant.d.ts",
|
|
23
|
+
"default": "./lib/invariant.js"
|
|
24
|
+
},
|
|
25
|
+
"./src/*": "./src/*",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"lib/index.js",
|
|
30
|
+
"lib/invariant.js",
|
|
31
|
+
"lib/types/**/*.d.ts"
|
|
32
|
+
],
|
|
33
|
+
"license": "BSD-3-Clause",
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.3",
|
|
36
|
+
"@deepseek-ai/dsh-fs": "^0.0.1-rc.3",
|
|
37
|
+
"@deepseek-ai/dsh-home-paths": "^0.0.1-rc.3",
|
|
38
|
+
"@deepseek-ai/dsh-skill": "^0.0.1-rc.3",
|
|
39
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"chokidar": "^5.0.0",
|
|
43
|
+
"yaml": "^2.4.2",
|
|
44
|
+
"@deepseek-ai/schemastery": "^3.18.1-rc.1"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@deepseek-ai/dsh-fs": "^0.0.1-rc.3",
|
|
48
|
+
"@deepseek-ai/dsh-home-paths": "^0.0.1-rc.3",
|
|
49
|
+
"@deepseek-ai/dsh-skill": "^0.0.1-rc.3",
|
|
50
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.3",
|
|
51
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1"
|
|
52
|
+
}
|
|
53
|
+
}
|