@deepseek-ai/dsh-settings-file 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 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.
@@ -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/settings/settings-file/README.md
5
+ README.md: 78ff50bd3f9b7c0bd52d50a837bc3cb7dad7c5be
6
+ README.zh.md: 2be0494bd7fd40cb59ff3999f5405adc49ff2a2d
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # @deepseek-ai/dsh-settings-file
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ File-backed settings provider. One YAML or JSON document carries every namespace section; external edits hot-publish through `ctx.settings`, and `update()` re-reads the document under a writer lock before writing back atomically, preserving the user's YAML comments, any section owned by a plugin that is not currently loaded, and any on-disk change this process has not observed yet.
6
+
7
+ ## Config
8
+
9
+ | Field | Meaning | Default |
10
+ |---|---|---|
11
+ | `path` | Settings document path; extension picks the format (`.yaml`/`.yml`/`.json`) | `settings.yaml` under the harness home |
12
+ | `dshHome` | Harness home used when `path` is omitted | `$DSH_HOME` or `~/.dsh` |
13
+ | `watch` | Watch the document and hot-publish external edits | `true` |
14
+ | `debounceMs` | Watcher write-settle window in milliseconds | `100` |
15
+
16
+ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension fails at load.
17
+
18
+ ## Behavior
19
+
20
+ - **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state.
21
+ - **Every write is a read-modify-write.** A persist first re-reads the document and publishes any difference into the seam — an external edit still inside the watcher debounce window, a change the watcher missed, or another process's write — then renders against that fresh text, so a write can never resurrect a stale document or drop an unobserved sibling section. If the on-disk document turned invalid, the write rejects loud instead of overwriting the user's manual edit.
22
+ - **Writes hold a cross-process writer lock.** The read-render-rename cycle runs under a `wx`-created `<file>.lock` sibling with exponential backoff and a 2 s acquisition deadline. A contender times out without removing the existing lock because age cannot distinguish a crashed owner from a paused live writer; orphan recovery is an operator action. Readers never take the lock: the rename commit is atomic, so reloads are always consistent.
23
+ - **Write-back is atomic, owner-only, and symlink-proof.** The render exclusive-creates a random-suffix temp sibling with mode `0600` (`wx` refuses to follow a planted symlink) and renames over the target, cleaning the temp up on failure.
24
+ - **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments.
25
+ - **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed.
26
+ - **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap.
27
+ - **The native watcher receives a canonical path.** Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix. File access and user-facing diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form event paths inside libuv.
28
+ - **Dispose quiesces in every watch mode.** Teardown marks the provider closed, closes the watcher when present, then waits out every queued or in-flight document operation, so nothing publishes after disposal.
29
+ - **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op.
30
+ - **Host configuration adapters receive the resolved path.** `ctx.settings.documentPath` is the absolute `resolveSpec()` filename, including a custom YAML/JSON path; `prepareDocument()` preserves an existing file or exclusively creates an absent empty file with owner-only permissions before the Host opens it. The browser receives only an availability flag, never reconstructs `$DSH_HOME`, and never submits a filesystem target.
31
+
32
+ ## Model Experience
33
+
34
+ Indirectly, through consumers of `ctx.settings`: this provider only stores and publishes namespace sections, and each consumer's own surface documents any model effect.
35
+
36
+ #### KV Cache effect
37
+
38
+ No direct invalidation; the consuming plugin owns any request-prefix changes.
39
+
40
+ ## Known Limitations and Deferred Work
41
+
42
+ - **Same-namespace conflicts stay last-write-wins** — the writer lock and read-modify-write keep concurrent writers from dropping each other's namespaces, but two writers editing one namespace still resolve to the later write; there is no per-value merge or revision check.
43
+ - **A missed watcher event stays unseen until the next signal** — reads never re-stat the file, so a change the watcher fails to report is only folded in by the next event, the next write, or a restart.
44
+ - **Comment preservation is YAML-only and map-shaped** — JSON documents re-serialize without comments (JSON has none), and comments inside a changed array (or attached inline to a changed scalar value) go with the value they described.
45
+ - **No value indirection** — sections hold literal values; `${env:VAR}`-style references for secrets are a deferred seam-level feature.
package/README.zh.md ADDED
@@ -0,0 +1,45 @@
1
+ # @deepseek-ai/dsh-settings-file
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 基于文件的设置提供方。一个 YAML 或 JSON 文档承载全部 namespace 分节;外部编辑经 `ctx.settings` 热发布,`update()` 在写锁下先重读文档再原子写回,保留用户的 YAML 注释、当前未加载插件所拥有的分节,以及任何本进程尚未观察到的磁盘变更。
6
+
7
+ ## 配置
8
+
9
+ | 字段 | 含义 | 默认 |
10
+ |---|---|---|
11
+ | `path` | 设置文档路径;扩展名决定格式(`.yaml`/`.yml`/`.json`) | harness home 下的 `settings.yaml` |
12
+ | `dshHome` | `path` 省略时使用的 harness home | `$DSH_HOME` 或 `~/.dsh` |
13
+ | `watch` | 监听文档并热发布外部编辑 | `true` |
14
+ | `debounceMs` | watcher 写入稳定窗口(毫秒) | `100` |
15
+
16
+ 默认值解析是一步显式的 `resolveSpec(config)`;不支持的扩展名在加载时报错。
17
+
18
+ ## 行为
19
+
20
+ - **启动失败并明确报错,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。
21
+ - **每次写入都是一次读-改-写。** persist 先重读文档并把任何差异发布进 seam——无论是仍在 watcher 防抖窗口内的外部编辑、watcher 漏掉的变更,还是另一个进程的写入——再基于这份新鲜文本渲染,因此写入绝不会复活陈旧文档,也不会丢掉未观察到的同级分节。若磁盘上的文档已变为非法,写入会明确报错并拒绝执行,而不是覆盖用户的手工编辑。
22
+ - **写入持有跨进程写锁。** 读-渲染-rename 流程在以 `wx` 创建的同级锁文件 `<file>.lock` 保护下运行,带指数退避与 2 s 的获取期限。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读取方从不取锁:rename 提交是原子的,重载因此始终一致。
23
+ - **写回原子、仅属主可访问、抗符号链接。** 渲染以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。
24
+ - **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。
25
+ - **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。
26
+ - **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。
27
+ - **原生 watcher 接收规范化路径。** 在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀。文件访问和面向用户的诊断仍使用配置路径,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。
28
+ - **dispose(资源释放)在每种 watch 模式下都保证完全停稳。** 卸载先把提供方标记为已关闭,在 watcher 存在时将其关闭,再等待所有已排队或进行中的文档操作完成,之后不再有任何发布。
29
+ - **按内容抑制自写。** 提供方缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
30
+ - **Host 配置适配器会收到解析后的路径。** `ctx.settings.documentPath` 是 `resolveSpec()` 得出的绝对文件名,包括自定义 YAML/JSON 路径;`prepareDocument()` 会保留现有文件,或在 Host 打开文档前,以仅属主可访问的权限独占创建缺失的空文件。浏览器只收到可用性标志,绝不重建 `$DSH_HOME`,也绝不提交文件系统目标。
31
+
32
+ ## 模型体验
33
+
34
+ 间接生效:本提供方只存储并发布 namespace 分节,任何模型效果都经由 `ctx.settings` 的消费方产生,并由各消费方自己的接口文档说明。
35
+
36
+ #### KV Cache 影响
37
+
38
+ 无直接失效;请求前缀的任何变更均由消费方插件负责。
39
+
40
+ ## 已知限制与暂缓事项
41
+
42
+ - **同 namespace 冲突仍是后写胜出** — 写锁加读-改-写让并发写入者不会丢掉彼此的 namespace,但两个写入者编辑同一个 namespace 时仍以较后的写入为准;没有按值合并,也没有修订检查。
43
+ - **漏掉的 watcher 事件在下一个信号前保持不可见** — 读取从不重新 stat 文件,因此 watcher 漏报的变更只会在下一个事件、下一次写入或重启时被并入。
44
+ - **注释保留仅限 YAML 且仅限 map 形状** — JSON 文档重新序列化,无注释(JSON 本身没有),且被改数组内部的注释(或行内附着在被改标量值上的注释)随其所描述的值一同被换掉。
45
+ - **无值间接引用** — 分节存字面值;面向密钥的 `${env:VAR}` 式引用是暂缓实现的 seam 层功能。
package/lib/index.js ADDED
@@ -0,0 +1,284 @@
1
+ import { Service } from "@deepseek-ai/cordis";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { watch } from "chokidar";
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { dirname, extname, join, resolve } from "node:path";
6
+ import { Document, parseDocument } from "yaml";
7
+ import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
8
+ import { canonicalizeWatchPath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
9
+ import { SettingsProvider, deepEqualJson } from "@deepseek-ai/dsh-settings";
10
+ //#region lib/types/index.js
11
+ /**
12
+ * File-backed settings provider. One YAML or JSON document under the user's
13
+ * harness home carries every namespace section; external edits hot-publish
14
+ * through the seam, and every write re-reads the document under a
15
+ * cross-process writer lock before patching it as a comment-preserving
16
+ * leaf-level diff.
17
+ * @module @deepseek-ai/dsh-settings-file
18
+ */
19
+ const FORMATS = {
20
+ ".yaml": "yaml",
21
+ ".yml": "yaml",
22
+ ".json": "json"
23
+ };
24
+ /**
25
+ * Resolve the runtime spec from plugin config: an explicit `path` wins,
26
+ * otherwise the document lives at `<harness home>/settings.yaml`.
27
+ * @param config - raw plugin config.
28
+ * @returns the resolved file location, format, and watch behavior.
29
+ */
30
+ function resolveSpec(config) {
31
+ const filename = resolve(config.path ?? join(resolveDshHome(config.dshHome), "settings.yaml"));
32
+ const format = FORMATS[extname(filename)];
33
+ if (format === void 0) throw new Error(`settings-file: extension "${extname(filename)}" is not supported (use .yaml, .yml, or .json)`);
34
+ return {
35
+ filename,
36
+ format,
37
+ watch: config.watch ?? true,
38
+ debounceMs: config.debounceMs ?? 100
39
+ };
40
+ }
41
+ /** Whether a parsed YAML value is a map for diffing purposes. */
42
+ function isMapLike(value) {
43
+ return typeof value === "object" && value !== null && !Array.isArray(value);
44
+ }
45
+ /**
46
+ * Apply the difference between one node's stored and next value as minimal
47
+ * `setIn`/`deleteIn` edits, recursing through maps, so every untouched node —
48
+ * and the key node of every changed pair — keeps its comments, anchors, and
49
+ * formatting. Non-map values (arrays and scalars) replace wholesale when
50
+ * unequal, taking any comments inside them along.
51
+ */
52
+ function patchNode(document, path, current, next) {
53
+ if (isMapLike(current) && isMapLike(next)) {
54
+ for (const key of Object.keys(current)) if (!(key in next)) document.deleteIn([...path, key]);
55
+ for (const [key, value] of Object.entries(next)) patchNode(document, [...path, key], current[key], value);
56
+ return;
57
+ }
58
+ if (!deepEqualJson(current, next)) document.setIn([...path], next);
59
+ }
60
+ /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
61
+ function isENOENT(error) {
62
+ return error?.code === "ENOENT";
63
+ }
64
+ /** Whether an exclusive file create found an existing document. */
65
+ function isEEXIST(error) {
66
+ return error?.code === "EEXIST";
67
+ }
68
+ /** File-backed settings provider (`settings.yaml`/`.json`). */
69
+ var FileSettingsProvider = class extends SettingsProvider {
70
+ config;
71
+ static Config = z.object({
72
+ path: z.string(),
73
+ dshHome: z.string(),
74
+ watch: z.boolean().default(true),
75
+ debounceMs: z.number().min(0).default(100)
76
+ });
77
+ spec;
78
+ /**
79
+ * Raw text of the last successfully parsed or persisted document;
80
+ * `undefined` while the file is absent. Watcher events whose content equals
81
+ * this cache are no-ops, which is also the self-write suppression.
82
+ */
83
+ text;
84
+ /**
85
+ * Single exclusive operation chain: watcher reloads and document writes run
86
+ * one at a time in queue order (settled tail), so a write can never render
87
+ * from text a concurrent reload is busy replacing, and a reload can never
88
+ * read a half-committed write.
89
+ */
90
+ operations = Promise.resolve();
91
+ /** Set at dispose: refuse new watcher events and let in-flight work no-op. */
92
+ closed = false;
93
+ /** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */
94
+ isClosed() {
95
+ return this.closed;
96
+ }
97
+ constructor(ctx, config) {
98
+ super(ctx);
99
+ this.config = config;
100
+ this.spec = resolveSpec(config);
101
+ }
102
+ /** The local document is always writable through {@link SettingsProvider.update}. */
103
+ get writable() {
104
+ return true;
105
+ }
106
+ /** The resolved YAML/JSON document path exposed to local configuration surfaces. */
107
+ get documentPath() {
108
+ return this.spec.filename;
109
+ }
110
+ /** Materialize an absent owner-only document, then return its resolved path. */
111
+ prepareDocument() {
112
+ return this.enqueue(async () => {
113
+ await mkdir(dirname(this.spec.filename), {
114
+ recursive: true,
115
+ mode: 448
116
+ });
117
+ await withFileLock(this.spec.filename, async () => {
118
+ try {
119
+ await writeFile(this.spec.filename, "", {
120
+ flag: "wx",
121
+ mode: 384
122
+ });
123
+ } catch (error) {
124
+ if (isEEXIST(error)) return;
125
+ throw error;
126
+ }
127
+ this.text = "";
128
+ if (!this.isClosed()) this.publish({});
129
+ });
130
+ return this.spec.filename;
131
+ });
132
+ }
133
+ async load() {
134
+ let text;
135
+ try {
136
+ text = await readFile(this.spec.filename, "utf8");
137
+ } catch (error) {
138
+ if (!isENOENT(error)) throw error;
139
+ this.text = void 0;
140
+ return {};
141
+ }
142
+ const doc = this.parse(text);
143
+ this.text = text;
144
+ return doc;
145
+ }
146
+ persist(ns, section) {
147
+ return this.enqueue(() => this.persistSection(ns, section));
148
+ }
149
+ /** Queue one exclusive document operation behind every earlier one. */
150
+ enqueue(operation) {
151
+ const task = this.operations.then(operation);
152
+ this.operations = task.then(() => void 0, () => void 0);
153
+ return task;
154
+ }
155
+ /** Queue a reload; only an invariant violation escaping a commit can reject it. */
156
+ queueRefresh() {
157
+ this.enqueue(() => this.refresh()).catch((error) => {
158
+ this.ctx.logger.error("settings-file: reload commit failed at %s", this.spec.filename);
159
+ this.ctx.logger.error(error);
160
+ });
161
+ }
162
+ async persistSection(ns, section) {
163
+ await mkdir(dirname(this.spec.filename), {
164
+ recursive: true,
165
+ mode: 448
166
+ });
167
+ await withFileLock(this.spec.filename, async () => {
168
+ await this.reconcileFromDisk();
169
+ const output = this.spec.format === "yaml" ? this.renderYaml(ns, section) : this.renderJson(ns, section);
170
+ await writeFileAtomic(this.spec.filename, output, {
171
+ mode: 384,
172
+ dirMode: 448
173
+ });
174
+ this.text = output;
175
+ });
176
+ }
177
+ async *[Service.init]() {
178
+ yield* super[Service.init]();
179
+ const watcher = this.spec.watch ? watch(await canonicalizeWatchPath(this.spec.filename), {
180
+ ignoreInitial: true,
181
+ awaitWriteFinish: {
182
+ stabilityThreshold: this.spec.debounceMs,
183
+ pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10))
184
+ }
185
+ }) : void 0;
186
+ if (watcher !== void 0) {
187
+ watcher.on("all", () => {
188
+ if (this.closed) return;
189
+ this.queueRefresh();
190
+ });
191
+ watcher.on("ready", () => {
192
+ if (this.closed) return;
193
+ this.queueRefresh();
194
+ });
195
+ watcher.on("error", (error) => {
196
+ this.ctx.logger.warn("settings-file: watcher error on %s", this.spec.filename);
197
+ this.ctx.logger.warn(error);
198
+ });
199
+ }
200
+ yield async () => {
201
+ this.closed = true;
202
+ await watcher?.close();
203
+ await this.operations;
204
+ };
205
+ }
206
+ /** Parse one document text into raw sections, failing on a non-map root. */
207
+ parse(text) {
208
+ let root;
209
+ if (this.spec.format === "yaml") {
210
+ const document = parseDocument(text, { prettyErrors: true });
211
+ if (document.errors.length > 0) throw new Error(`settings-file: invalid document at ${this.spec.filename}: ${document.errors.map((error) => {
212
+ const at = error.linePos?.[0];
213
+ /* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */
214
+ return `${error.code}${at === void 0 ? "" : ` at line ${String(at.line)}, column ${String(at.col)}`}`;
215
+ }).join("; ")}`);
216
+ root = document.toJS() ?? {};
217
+ } else root = text.trim().length === 0 ? {} : JSON.parse(text);
218
+ if (typeof root !== "object" || root === null || Array.isArray(root)) throw new TypeError(`settings-file: ${this.spec.filename} must be a map of namespace sections`);
219
+ return root;
220
+ }
221
+ /**
222
+ * Re-read the document after a watcher event. Unchanged content (including
223
+ * this provider's own writes) is a no-op; an unreadable or unparsable
224
+ * document keeps the last good sections and warns — a live hot-reload must
225
+ * never take the process down. An invariant violation escaping a commit is
226
+ * not a reload failure and propagates to the queue's error surface.
227
+ */
228
+ async refresh() {
229
+ if (this.closed) return;
230
+ try {
231
+ await this.reconcileFromDisk();
232
+ } catch (error) {
233
+ if (error?.code === "INVARIANT") throw error;
234
+ this.ctx.logger.warn("settings-file: reload failed at %s; keeping the last good document", this.spec.filename);
235
+ this.ctx.logger.warn(error);
236
+ }
237
+ }
238
+ /**
239
+ * Compare the on-disk text against the cache and publish any difference
240
+ * into the seam. Absence publishes the empty document; an unreadable or
241
+ * unparsable file throws, so each caller picks its policy — a reload warns
242
+ * and keeps the last good document, a write fails loud.
243
+ */
244
+ async reconcileFromDisk() {
245
+ let text;
246
+ try {
247
+ text = await readFile(this.spec.filename, "utf8");
248
+ } catch (error) {
249
+ if (!isENOENT(error)) throw error;
250
+ text = void 0;
251
+ }
252
+ if (text === this.text || this.isClosed()) return;
253
+ if (text === void 0) {
254
+ this.text = void 0;
255
+ this.publish({});
256
+ return;
257
+ }
258
+ const doc = this.parse(text);
259
+ this.text = text;
260
+ this.publish(doc);
261
+ }
262
+ /**
263
+ * Render the next YAML text by patching one namespace in the
264
+ * comment-preserving document. The next section lands as a leaf-level diff
265
+ * against the stored one — only changed values set, only removed keys
266
+ * delete — so comments inside the section survive edits to their siblings,
267
+ * not just comments outside it.
268
+ */
269
+ renderYaml(ns, section) {
270
+ if (this.text === void 0) return new Document({ [ns]: section }).toString();
271
+ const document = parseDocument(this.text);
272
+ const root = document.toJS();
273
+ patchNode(document, [ns], isMapLike(root) ? root[ns] : void 0, section);
274
+ return document.toString();
275
+ }
276
+ /** Render the next JSON text by replacing one namespace key. */
277
+ renderJson(ns, section) {
278
+ const root = this.text === void 0 ? {} : this.parse(this.text);
279
+ root[ns] = section;
280
+ return `${JSON.stringify(root, null, 2)}\n`;
281
+ }
282
+ };
283
+ //#endregion
284
+ export { FileSettingsProvider, FileSettingsProvider as default, resolveSpec };
@@ -0,0 +1,24 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@deepseek-ai/dsh-settings-file`.
4
+ * @module @deepseek-ai/dsh-settings-file/invariant
5
+ */
6
+ const PACKAGE_NAME = "@deepseek-ai/dsh-settings-file";
7
+ /** Cordis companion plugin name. */
8
+ const name = "settings-file-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * No runtime invariant: this provider's contracts are file round-trip,
13
+ * watcher timing, and atomic-write behavior — IO effects proven by package
14
+ * tests; the in-process commit relation is owned by `@deepseek-ai/dsh-settings`.
15
+ */
16
+ const install = () => {};
17
+ /**
18
+ * Register this package's invariant companion.
19
+ * @param ctx - Cordis context carrying the invariant service.
20
+ * @returns the installed registration's disposer after setup succeeds.
21
+ */
22
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
23
+ //#endregion
24
+ export { apply, inject, name };
@@ -0,0 +1,105 @@
1
+ /**
2
+ * File-backed settings provider. One YAML or JSON document under the user's
3
+ * harness home carries every namespace section; external edits hot-publish
4
+ * through the seam, and every write re-reads the document under a
5
+ * cross-process writer lock before patching it as a comment-preserving
6
+ * leaf-level diff.
7
+ * @module @deepseek-ai/dsh-settings-file
8
+ */
9
+ import { Context, Service } from '@deepseek-ai/cordis';
10
+ import z from '@deepseek-ai/schemastery';
11
+ import { SettingsProvider, type SettingsNamespace } from '@deepseek-ai/dsh-settings';
12
+ /** Plugin config: file location and hot-reload behavior. */
13
+ export interface Config {
14
+ /** Settings document path; defaults to `settings.yaml` under the harness home. */
15
+ path?: string;
16
+ /** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
17
+ dshHome?: string;
18
+ /** Watch the document and hot-publish external edits; defaults to true. */
19
+ watch?: boolean;
20
+ /** Watcher write-settle window in milliseconds; defaults to 100. */
21
+ debounceMs?: number;
22
+ }
23
+ /** Document format derived from the configured file extension. */
24
+ type SettingsFormat = 'yaml' | 'json';
25
+ /** Fully resolved provider parameters; defaulting happens here, never inline. */
26
+ interface ResolvedSpec {
27
+ filename: string;
28
+ format: SettingsFormat;
29
+ watch: boolean;
30
+ debounceMs: number;
31
+ }
32
+ /**
33
+ * Resolve the runtime spec from plugin config: an explicit `path` wins,
34
+ * otherwise the document lives at `<harness home>/settings.yaml`.
35
+ * @param config - raw plugin config.
36
+ * @returns the resolved file location, format, and watch behavior.
37
+ */
38
+ export declare function resolveSpec(config: Config): ResolvedSpec;
39
+ /** File-backed settings provider (`settings.yaml`/`.json`). */
40
+ export declare class FileSettingsProvider extends SettingsProvider {
41
+ config: Config;
42
+ static Config: z<Config>;
43
+ private readonly spec;
44
+ /**
45
+ * Raw text of the last successfully parsed or persisted document;
46
+ * `undefined` while the file is absent. Watcher events whose content equals
47
+ * this cache are no-ops, which is also the self-write suppression.
48
+ */
49
+ private text;
50
+ /**
51
+ * Single exclusive operation chain: watcher reloads and document writes run
52
+ * one at a time in queue order (settled tail), so a write can never render
53
+ * from text a concurrent reload is busy replacing, and a reload can never
54
+ * read a half-committed write.
55
+ */
56
+ private operations;
57
+ /** Set at dispose: refuse new watcher events and let in-flight work no-op. */
58
+ private closed;
59
+ /** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */
60
+ private isClosed;
61
+ constructor(ctx: Context, config: Config);
62
+ /** The local document is always writable through {@link SettingsProvider.update}. */
63
+ get writable(): boolean;
64
+ /** The resolved YAML/JSON document path exposed to local configuration surfaces. */
65
+ get documentPath(): string;
66
+ /** Materialize an absent owner-only document, then return its resolved path. */
67
+ prepareDocument(): Promise<string>;
68
+ protected load(): Promise<Record<string, unknown>>;
69
+ protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void>;
70
+ /** Queue one exclusive document operation behind every earlier one. */
71
+ private enqueue;
72
+ /** Queue a reload; only an invariant violation escaping a commit can reject it. */
73
+ private queueRefresh;
74
+ private persistSection;
75
+ [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void>;
76
+ /** Parse one document text into raw sections, failing on a non-map root. */
77
+ private parse;
78
+ /**
79
+ * Re-read the document after a watcher event. Unchanged content (including
80
+ * this provider's own writes) is a no-op; an unreadable or unparsable
81
+ * document keeps the last good sections and warns — a live hot-reload must
82
+ * never take the process down. An invariant violation escaping a commit is
83
+ * not a reload failure and propagates to the queue's error surface.
84
+ */
85
+ private refresh;
86
+ /**
87
+ * Compare the on-disk text against the cache and publish any difference
88
+ * into the seam. Absence publishes the empty document; an unreadable or
89
+ * unparsable file throws, so each caller picks its policy — a reload warns
90
+ * and keeps the last good document, a write fails loud.
91
+ */
92
+ private reconcileFromDisk;
93
+ /**
94
+ * Render the next YAML text by patching one namespace in the
95
+ * comment-preserving document. The next section lands as a leaf-level diff
96
+ * against the stored one — only changed values set, only removed keys
97
+ * delete — so comments inside the section survive edits to their siblings,
98
+ * not just comments outside it.
99
+ */
100
+ private renderYaml;
101
+ /** Render the next JSON text by replacing one namespace key. */
102
+ private renderJson;
103
+ }
104
+ export default FileSettingsProvider;
105
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `@deepseek-ai/dsh-settings-file`.
3
+ * @module @deepseek-ai/dsh-settings-file/invariant
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "settings-file-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-settings-file",
3
+ "description": "File-backed settings provider (settings.yaml) 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/settings/settings-file"
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-atomic-write": "^0.0.1-rc.3",
36
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.3",
37
+ "@deepseek-ai/dsh-home-paths": "^0.0.1-rc.3",
38
+ "@deepseek-ai/dsh-settings": "^0.0.1-rc.3",
39
+ "@deepseek-ai/cordis": "^4.0.1-rc.1"
40
+ },
41
+ "dependencies": {
42
+ "chokidar": "^4.0.3",
43
+ "yaml": "^2.9.0",
44
+ "@deepseek-ai/schemastery": "^3.18.1-rc.1"
45
+ },
46
+ "devDependencies": {
47
+ "@deepseek-ai/dsh-atomic-write": "^0.0.1-rc.3",
48
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.3",
49
+ "@deepseek-ai/dsh-home-paths": "^0.0.1-rc.3",
50
+ "@deepseek-ai/dsh-settings": "^0.0.1-rc.3",
51
+ "@deepseek-ai/cordis": "^4.0.1-rc.1"
52
+ }
53
+ }