dsh-subagent-profile 0.3.3 → 0.3.4

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.
@@ -1,256 +1,254 @@
1
- // lib/core/presets-sync.mjs — bundled agent 预设自安装(从 index.mjs 逐字移出;
2
- // 仅 node 内置,无 @deepseek-ai 依赖)。
3
- //
4
- // 宿主启动时插件把 bundled `presets/` 树同步进 DSH agent-presets 发现根
5
- // (~/.dsh/.agent-presets),使「编排者模式」无需手动复制就出现在新建会话
6
- // 选择器里——与官方 dsh-liangshen bundle 相同的自安装模式。同步按目录、
7
- // 经哈希门控:per-target sidecar(.dsh-sync.json)记录 bundled 内容哈希与派生树
8
- // 快照哈希,下次运行跳过未变化的树、跳过(绝不覆盖)用户改过的树;两侧都变时
9
- // 先把用户侧归档到 .user-modified-<timestamp>/ 再写入升级后的 bundled 内容。
10
- // bundle 不再提供的目标文件会被剪除;插件不拥有的目录永不触碰。
11
- // 刻意不用 node:fs cpSync:Node 22 的 Windows 上,fs.cpSync({ recursive: true })
12
- // 在源路径含非 ASCII(CJK 主目录,nodejs/node#54476)时可能崩溃进程,故逐条
13
- // 复制并保留源 mtime。
14
-
15
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
16
- import { basename, dirname, join, relative, sep } from 'node:path';
17
- import { fileURLToPath } from 'node:url';
18
- import { createHash } from 'node:crypto';
19
-
20
- // 本包内 bundled 预设树的绝对路径。本模块在 lib/core/,比包根低两层,URL 必须
21
- // 上爬两段才能指向仓库根 `presets/` 目录(与它还在包根的 index.mjs 时解析到的
22
- // 值相同;Task 8.6 把模块从 lib/ 挪到 lib/core/ 后爬升必须写成 `../../` 而不是
23
- // `../`——preflight preset 树对账抓到了那个错误版本指向不存在的 lib/presets,
24
- // 它会静默禁用启动自安装)。
25
- export function bundledPresetsRoot() {
26
- return fileURLToPath(new URL('../../presets', import.meta.url));
27
- }
28
-
29
- export const MTIME_TOLERANCE_MS = 1000;
30
-
31
- // 同步元数据:sidecar 记录最近一次 bundled/目标内容哈希,归档前缀命名三方
32
- // 用户编辑备份。两者都是插件本地记账、非 preset 内容——hashTree 排除它们、
33
- // prune 保留它们(否则 sidecar 自身写入会让目标哈希自指漂移,归档会被剪掉)。
34
- export const SIDECAR_NAME = '.dsh-sync.json';
35
- export const ARCHIVE_PREFIX = '.user-modified-';
36
-
37
- export function filesUnder(root) {
38
- const out = [];
39
- const walk = (dir) => {
40
- for (const entry of readdirSync(dir)) {
41
- const path = join(dir, entry);
42
- if (statSync(path).isDirectory()) walk(path);
43
- else out.push(path);
44
- }
45
- };
46
- walk(root);
47
- return out;
48
- }
49
-
50
- // 文件同一性按字节;size/mtime 只作快速否定检查。
51
- export function sameFile(a, b) {
52
- const sa = statSync(a);
53
- const sb = statSync(b);
54
- if (sa.size !== sb.size) return false;
55
- if (Math.abs(sa.mtimeMs - sb.mtimeMs) > MTIME_TOLERANCE_MS) return false;
56
- return readFileSync(a).equals(readFileSync(b));
57
- }
58
-
59
- export function copyTreeSync(sourceDir, targetDir) {
60
- mkdirSync(targetDir, { recursive: true });
61
- for (const entry of readdirSync(sourceDir)) {
62
- const source = join(sourceDir, entry);
63
- const target = join(targetDir, entry);
64
- const st = statSync(source);
65
- if (st.isDirectory()) copyTreeSync(source, target);
66
- else {
67
- copyFileSync(source, target);
68
- utimesSync(target, st.atime, st.mtime);
69
- }
70
- }
71
- }
72
-
73
- // 删除 `keep` 之外的 target 文件,再只删因此清空的目录。
74
- export function pruneExtras(root, keep) {
75
- const parents = new Set();
76
- for (const file of filesUnder(root)) {
77
- if (!keep.has(relative(root, file))) {
78
- parents.add(dirname(file));
79
- rmSync(file, { force: true });
80
- }
81
- }
82
- for (const start of parents) {
83
- let dir = start;
84
- while (dir !== undefined && relative(root, dir) !== '') {
85
- if (existsSync(dir) && readdirSync(dir).length === 0) {
86
- rmSync(dir, { recursive: true, force: true });
87
- dir = dirname(dir);
88
- } else dir = undefined;
89
- }
90
- }
91
- }
92
-
93
- // preset 树内容哈希:每文件的相对路径 + 字节喂进 sha1。同步元数据(sidecar
94
- // 任何 `.user-modified-*` 归档)被排除——它是插件记账、非 preset 内容,纳入会使
95
- // 派生树哈希在 sidecar 被重写(自指)或归档创建时立即漂移。
96
- export function hashTree(root) {
97
- const hash = createHash('sha1');
98
- for (const file of filesUnder(root).sort()) {
99
- const rel = relative(root, file);
100
- if (isSyncMetadata(rel)) continue;
101
- hash.update(rel);
102
- hash.update('\0');
103
- hash.update(readFileSync(file));
104
- hash.update('\0');
105
- }
106
- return hash.digest('hex');
107
- }
108
-
109
- // 相对 preset 根的路径是否为同步元数据(sidecar 文件,或 `.user-modified-*`
110
- // 归档目录下的任何内容)。
111
- export function isSyncMetadata(rel) {
112
- const head = rel.split(sep)[0];
113
- return head === SIDECAR_NAME || head.startsWith(ARCHIVE_PREFIX);
114
- }
115
-
116
- export function sidecarPath(targetDir) {
117
- return join(targetDir, SIDECAR_NAME);
118
- }
119
-
120
- // sidecar;缺失或畸形(版本错 / 哈希字段缺 / 解析失败)按缺失处理,使下次
121
- // 同步走保守的首跑路径,而不是信任损坏的记账。
122
- function readSidecar(targetDir) {
123
- const path = sidecarPath(targetDir);
124
- if (!existsSync(path)) return undefined;
125
- try {
126
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
127
- if (parsed && parsed.version === 1 && typeof parsed.bundledHash === 'string' && typeof parsed.targetHash === 'string') {
128
- return parsed;
129
- }
130
- return undefined;
131
- } catch {
132
- return undefined;
133
- }
134
- }
135
-
136
- function writeSidecar(targetDir, bundledHash, targetHash) {
137
- writeFileSync(sidecarPath(targetDir), `${JSON.stringify({ version: 1, bundledHash, targetHash }, null, 2)}\n`);
138
- }
139
-
140
- // 复制一个文件系统条目(文件或目录),保留逐条语义与源 mtime——与 copyTreeSync
141
- // 自身文件分支同形。
142
- function copyEntry(source, dest) {
143
- const st = statSync(source);
144
- if (st.isDirectory()) copyTreeSync(source, dest);
145
- else {
146
- copyFileSync(source, dest);
147
- utimesSync(dest, st.atime, st.mtime);
148
- }
149
- }
150
-
151
- // 覆盖前把当前(用户可见的)preset 树归档到
152
- // <targetDir>/.user-modified-<timestamp>/。sidecar 与既有归档被排除,归档不会
153
- // 嵌套。写失败返回 false——调用方必须跳过本次同步、保留用户编辑原样
154
- // (fail-safe:备份失败绝不允许成为销毁用户内容的许可)。
155
- function archiveUserTree(targetDir) {
156
- const archiveDir = join(targetDir, `${ARCHIVE_PREFIX}${Date.now()}`);
157
- // 先拍下要归档的条目再创建 archiveDir:归档位于 targetDir 之下,mkdirSync 后
158
- // 再读目录会把归档本身读进来,无限递归进它自己。
159
- const entries = [];
160
- for (const entry of readdirSync(targetDir)) {
161
- if (entry === SIDECAR_NAME || entry.startsWith(ARCHIVE_PREFIX)) continue;
162
- entries.push(entry);
163
- }
164
- try {
165
- mkdirSync(archiveDir, { recursive: true });
166
- for (const entry of entries) {
167
- copyEntry(join(targetDir, entry), join(archiveDir, entry));
168
- }
169
- return true;
170
- } catch {
171
- try { rmSync(archiveDir, { recursive: true, force: true }); } catch { /* best effort */ }
172
- return false;
173
- }
174
- }
175
-
176
- // pruneExtras 的 keep 集:同步后树必须保留的一切——bundled 文件、sidecar、
177
- // 既有 `.user-modified-*` 归档下的每个文件。keep 里没有归档条目的话,
178
- // pruneExtras 会在同步当下删掉归档,静默丢弃它本该保留的用户编辑。
179
- function computeKeep(sourceDir, targetDir) {
180
- const keep = new Set(filesUnder(sourceDir).map((f) => relative(sourceDir, f)));
181
- keep.add(SIDECAR_NAME);
182
- if (existsSync(targetDir)) {
183
- for (const entry of readdirSync(targetDir)) {
184
- if (!entry.startsWith(ARCHIVE_PREFIX)) continue;
185
- const dir = join(targetDir, entry);
186
- if (!statSync(dir).isDirectory()) continue;
187
- for (const f of filesUnder(dir)) keep.add(relative(targetDir, f));
188
- }
189
- }
190
- return keep;
191
- }
192
-
193
- // 同步一个 preset 目录。返回 'synced'(写入或升级)、'current'(未变)或
194
- // 'user-modified'(target 偏离上次同步快照而跳过——绝不覆盖用户编辑)。判定表:
195
- // - target 不存在 复制 + 写 sidecar → 'synced'
196
- // - sidecar、target 存在 一次性归档 + 同步 → 'synced'
197
- // - bundled 未变、target 相同 → 'current'
198
- // - bundled 未变、target 漂移 → 'user-modified'(跳过)
199
- // - bundled 变了 → 漂移则先归档,再同步 → 'synced'
200
- export function syncOnePreset(sourceDir, targetDir) {
201
- const bundledHash = hashTree(sourceDir);
202
- if (existsSync(targetDir) && !statSync(targetDir).isDirectory()) {
203
- // M8:target 意外是文件(同名冲突)时先改名备份再清,避免直接 rmSync 丢失用户内容。
204
- try { renameSync(targetDir, `${targetDir}.removed-${Date.now()}`); } catch { rmSync(targetDir, { recursive: true, force: true }); }
205
- }
206
- if (!existsSync(targetDir)) {
207
- copyTreeSync(sourceDir, targetDir);
208
- writeSidecar(targetDir, bundledHash, hashTree(targetDir));
209
- return 'synced';
210
- }
211
- const sidecar = readSidecar(targetDir);
212
- // sidecar + 既有 target = 门控前状态:无法判断用户是否编辑过,按「可能编辑
213
- // 过」处理,在首次门控同步前归档一次(一次性迁移)。
214
- if (sidecar === undefined) {
215
- if (!archiveUserTree(targetDir)) return 'user-modified';
216
- copyTreeSync(sourceDir, targetDir);
217
- pruneExtras(targetDir, computeKeep(sourceDir, targetDir));
218
- writeSidecar(targetDir, bundledHash, hashTree(targetDir));
219
- return 'synced';
220
- }
221
- const currentTargetHash = hashTree(targetDir);
222
- if (sidecar.bundledHash === bundledHash) {
223
- return sidecar.targetHash === currentTargetHash ? 'current' : 'user-modified';
224
- }
225
- // bundled 升级。用户也改过 target(快照漂移)时先归档其树,编辑不会静默丢失。
226
- if (sidecar.targetHash !== currentTargetHash) {
227
- if (!archiveUserTree(targetDir)) return 'user-modified';
228
- }
229
- copyTreeSync(sourceDir, targetDir);
230
- pruneExtras(targetDir, computeKeep(sourceDir, targetDir));
231
- writeSidecar(targetDir, bundledHash, hashTree(targetDir));
232
- return 'synced';
233
- }
234
-
235
- // `presets/` 下每个 preset 目录同步进目标发现根。
236
- export function syncBundledPresets(targetRoot) {
237
- const result = { synced: [], current: [], userModified: [], failed: [] };
238
- const sourceRoot = bundledPresetsRoot();
239
- mkdirSync(targetRoot, { recursive: true });
240
- if (existsSync(sourceRoot)) {
241
- for (const entry of readdirSync(sourceRoot)) {
242
- const source = join(sourceRoot, entry);
243
- if (!statSync(source).isDirectory()) continue;
244
- const id = basename(source);
245
- try {
246
- const outcome = syncOnePreset(source, join(targetRoot, id));
247
- if (outcome === 'synced') result.synced.push(id);
248
- else if (outcome === 'current') result.current.push(id);
249
- else result.userModified.push(id);
250
- } catch (error) {
251
- result.failed.push({ id, error: error instanceof Error ? error.message : String(error) });
252
- }
253
- }
254
- }
255
- return result;
256
- }
1
+ // lib/core/presets-sync.mjs — bundled agent 预设自安装(从 index.mjs 逐字移出;
2
+ // 仅 node 内置,无 @deepseek-ai 依赖)。
3
+ //
4
+ // 宿主启动时插件把 bundled `presets/` 树同步进 DSH agent-presets 发现根
5
+ // (~/.dsh/.agent-presets),使「编排者模式」无需手动复制就出现在新建会话
6
+ // 选择器里——与官方 dsh-liangshen bundle 相同的自安装模式。同步按目录、
7
+ // 经哈希门控:per-target sidecar(.dsh-sync.json)记录 bundled 内容哈希与派生树
8
+ // 快照哈希,下次运行跳过未变化的树、跳过(绝不覆盖)用户改过的树;两侧都变时
9
+ // 先把用户侧归档到 .user-modified-<timestamp>/ 再写入升级后的 bundled 内容。
10
+ // bundle 不再提供的目标文件会被剪除;插件不拥有的目录永不触碰。
11
+ // 刻意不用 node:fs cpSync:Node 22 的 Windows 上,fs.cpSync({ recursive: true })
12
+ // 在源路径含非 ASCII(CJK 主目录,nodejs/node#54476)时可能崩溃进程,故逐条
13
+ // 复制并保留源 mtime。
14
+
15
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
16
+ import { basename, dirname, join, relative, sep } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { createHash } from 'node:crypto';
19
+
20
+ // 本包内 bundled 预设树的绝对路径。本模块在 lib/core/,比包根低两层,URL 必须
21
+ // 上爬两段才能指向仓库根 `presets/` 目录(与它还在包根的 index.mjs 时解析到的
22
+ // 值相同;写错成 `../` 会指向不存在的 lib/presets,从而静默禁用启动自安装)。
23
+ export function bundledPresetsRoot() {
24
+ return fileURLToPath(new URL('../../presets', import.meta.url));
25
+ }
26
+
27
+ export const MTIME_TOLERANCE_MS = 1000;
28
+
29
+ // 同步元数据:sidecar 记录最近一次 bundled/目标内容哈希,归档前缀命名三方
30
+ // 用户编辑备份。两者都是插件本地记账、非 preset 内容——hashTree 排除它们、
31
+ // prune 保留它们(否则 sidecar 自身写入会让目标哈希自指漂移,归档会被剪掉)。
32
+ export const SIDECAR_NAME = '.dsh-sync.json';
33
+ export const ARCHIVE_PREFIX = '.user-modified-';
34
+
35
+ export function filesUnder(root) {
36
+ const out = [];
37
+ const walk = (dir) => {
38
+ for (const entry of readdirSync(dir)) {
39
+ const path = join(dir, entry);
40
+ if (statSync(path).isDirectory()) walk(path);
41
+ else out.push(path);
42
+ }
43
+ };
44
+ walk(root);
45
+ return out;
46
+ }
47
+
48
+ // 文件同一性按字节;size/mtime 只作快速否定检查。
49
+ export function sameFile(a, b) {
50
+ const sa = statSync(a);
51
+ const sb = statSync(b);
52
+ if (sa.size !== sb.size) return false;
53
+ if (Math.abs(sa.mtimeMs - sb.mtimeMs) > MTIME_TOLERANCE_MS) return false;
54
+ return readFileSync(a).equals(readFileSync(b));
55
+ }
56
+
57
+ export function copyTreeSync(sourceDir, targetDir) {
58
+ mkdirSync(targetDir, { recursive: true });
59
+ for (const entry of readdirSync(sourceDir)) {
60
+ const source = join(sourceDir, entry);
61
+ const target = join(targetDir, entry);
62
+ const st = statSync(source);
63
+ if (st.isDirectory()) copyTreeSync(source, target);
64
+ else {
65
+ copyFileSync(source, target);
66
+ utimesSync(target, st.atime, st.mtime);
67
+ }
68
+ }
69
+ }
70
+
71
+ // 删除 `keep` 之外的 target 文件,再只删因此清空的目录。
72
+ export function pruneExtras(root, keep) {
73
+ const parents = new Set();
74
+ for (const file of filesUnder(root)) {
75
+ if (!keep.has(relative(root, file))) {
76
+ parents.add(dirname(file));
77
+ rmSync(file, { force: true });
78
+ }
79
+ }
80
+ for (const start of parents) {
81
+ let dir = start;
82
+ while (dir !== undefined && relative(root, dir) !== '') {
83
+ if (existsSync(dir) && readdirSync(dir).length === 0) {
84
+ rmSync(dir, { recursive: true, force: true });
85
+ dir = dirname(dir);
86
+ } else dir = undefined;
87
+ }
88
+ }
89
+ }
90
+
91
+ // preset 树内容哈希:每文件的相对路径 + 字节喂进 sha1。同步元数据(sidecar 与
92
+ // 任何 `.user-modified-*` 归档)被排除——它是插件记账、非 preset 内容,纳入会使
93
+ // 派生树哈希在 sidecar 被重写(自指)或归档创建时立即漂移。
94
+ export function hashTree(root) {
95
+ const hash = createHash('sha1');
96
+ for (const file of filesUnder(root).sort()) {
97
+ const rel = relative(root, file);
98
+ if (isSyncMetadata(rel)) continue;
99
+ hash.update(rel);
100
+ hash.update('\0');
101
+ hash.update(readFileSync(file));
102
+ hash.update('\0');
103
+ }
104
+ return hash.digest('hex');
105
+ }
106
+
107
+ // 相对 preset 根的路径是否为同步元数据(sidecar 文件,或 `.user-modified-*`
108
+ // 归档目录下的任何内容)。
109
+ export function isSyncMetadata(rel) {
110
+ const head = rel.split(sep)[0];
111
+ return head === SIDECAR_NAME || head.startsWith(ARCHIVE_PREFIX);
112
+ }
113
+
114
+ export function sidecarPath(targetDir) {
115
+ return join(targetDir, SIDECAR_NAME);
116
+ }
117
+
118
+ // 读 sidecar;缺失或畸形(版本错 / 哈希字段缺 / 解析失败)按缺失处理,使下次
119
+ // 同步走保守的首跑路径,而不是信任损坏的记账。
120
+ function readSidecar(targetDir) {
121
+ const path = sidecarPath(targetDir);
122
+ if (!existsSync(path)) return undefined;
123
+ try {
124
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
125
+ if (parsed && parsed.version === 1 && typeof parsed.bundledHash === 'string' && typeof parsed.targetHash === 'string') {
126
+ return parsed;
127
+ }
128
+ return undefined;
129
+ } catch {
130
+ return undefined;
131
+ }
132
+ }
133
+
134
+ function writeSidecar(targetDir, bundledHash, targetHash) {
135
+ writeFileSync(sidecarPath(targetDir), `${JSON.stringify({ version: 1, bundledHash, targetHash }, null, 2)}\n`);
136
+ }
137
+
138
+ // 复制一个文件系统条目(文件或目录),保留逐条语义与源 mtime——与 copyTreeSync
139
+ // 自身文件分支同形。
140
+ function copyEntry(source, dest) {
141
+ const st = statSync(source);
142
+ if (st.isDirectory()) copyTreeSync(source, dest);
143
+ else {
144
+ copyFileSync(source, dest);
145
+ utimesSync(dest, st.atime, st.mtime);
146
+ }
147
+ }
148
+
149
+ // 覆盖前把当前(用户可见的)preset 树归档到
150
+ // <targetDir>/.user-modified-<timestamp>/。sidecar 与既有归档被排除,归档不会
151
+ // 嵌套。写失败返回 false——调用方必须跳过本次同步、保留用户编辑原样
152
+ // (fail-safe:备份失败绝不允许成为销毁用户内容的许可)。
153
+ function archiveUserTree(targetDir) {
154
+ const archiveDir = join(targetDir, `${ARCHIVE_PREFIX}${Date.now()}`);
155
+ // 先拍下要归档的条目再创建 archiveDir:归档位于 targetDir 之下,mkdirSync 后
156
+ // 再读目录会把归档本身读进来,无限递归进它自己。
157
+ const entries = [];
158
+ for (const entry of readdirSync(targetDir)) {
159
+ if (entry === SIDECAR_NAME || entry.startsWith(ARCHIVE_PREFIX)) continue;
160
+ entries.push(entry);
161
+ }
162
+ try {
163
+ mkdirSync(archiveDir, { recursive: true });
164
+ for (const entry of entries) {
165
+ copyEntry(join(targetDir, entry), join(archiveDir, entry));
166
+ }
167
+ return true;
168
+ } catch {
169
+ try { rmSync(archiveDir, { recursive: true, force: true }); } catch { /* best effort */ }
170
+ return false;
171
+ }
172
+ }
173
+
174
+ // pruneExtras 的 keep 集:同步后树必须保留的一切——bundled 文件、sidecar、
175
+ // 既有 `.user-modified-*` 归档下的每个文件。keep 里没有归档条目的话,
176
+ // pruneExtras 会在同步当下删掉归档,静默丢弃它本该保留的用户编辑。
177
+ function computeKeep(sourceDir, targetDir) {
178
+ const keep = new Set(filesUnder(sourceDir).map((f) => relative(sourceDir, f)));
179
+ keep.add(SIDECAR_NAME);
180
+ if (existsSync(targetDir)) {
181
+ for (const entry of readdirSync(targetDir)) {
182
+ if (!entry.startsWith(ARCHIVE_PREFIX)) continue;
183
+ const dir = join(targetDir, entry);
184
+ if (!statSync(dir).isDirectory()) continue;
185
+ for (const f of filesUnder(dir)) keep.add(relative(targetDir, f));
186
+ }
187
+ }
188
+ return keep;
189
+ }
190
+
191
+ // 同步一个 preset 目录。返回 'synced'(写入或升级)、'current'(未变)或
192
+ // 'user-modified'(target 偏离上次同步快照而跳过——绝不覆盖用户编辑)。判定表:
193
+ // - target 不存在 → 复制 + 写 sidecar → 'synced'
194
+ // - 无 sidecar、target 存在 → 一次性归档 + 同步 → 'synced'
195
+ // - bundled 未变、target 相同 → 'current'
196
+ // - bundled 未变、target 漂移 → 'user-modified'(跳过)
197
+ // - bundled 变了 → 漂移则先归档,再同步 → 'synced'
198
+ export function syncOnePreset(sourceDir, targetDir) {
199
+ const bundledHash = hashTree(sourceDir);
200
+ if (existsSync(targetDir) && !statSync(targetDir).isDirectory()) {
201
+ // target 意外是文件(同名冲突)时先改名备份再清,避免直接 rmSync 丢失用户内容。
202
+ try { renameSync(targetDir, `${targetDir}.removed-${Date.now()}`); } catch { rmSync(targetDir, { recursive: true, force: true }); }
203
+ }
204
+ if (!existsSync(targetDir)) {
205
+ copyTreeSync(sourceDir, targetDir);
206
+ writeSidecar(targetDir, bundledHash, hashTree(targetDir));
207
+ return 'synced';
208
+ }
209
+ const sidecar = readSidecar(targetDir);
210
+ // 无 sidecar + 既有 target = 门控前状态:无法判断用户是否编辑过,按「可能编辑
211
+ // 过」处理,在首次门控同步前归档一次(一次性迁移)。
212
+ if (sidecar === undefined) {
213
+ if (!archiveUserTree(targetDir)) return 'user-modified';
214
+ copyTreeSync(sourceDir, targetDir);
215
+ pruneExtras(targetDir, computeKeep(sourceDir, targetDir));
216
+ writeSidecar(targetDir, bundledHash, hashTree(targetDir));
217
+ return 'synced';
218
+ }
219
+ const currentTargetHash = hashTree(targetDir);
220
+ if (sidecar.bundledHash === bundledHash) {
221
+ return sidecar.targetHash === currentTargetHash ? 'current' : 'user-modified';
222
+ }
223
+ // bundled 升级。用户也改过 target(快照漂移)时先归档其树,编辑不会静默丢失。
224
+ if (sidecar.targetHash !== currentTargetHash) {
225
+ if (!archiveUserTree(targetDir)) return 'user-modified';
226
+ }
227
+ copyTreeSync(sourceDir, targetDir);
228
+ pruneExtras(targetDir, computeKeep(sourceDir, targetDir));
229
+ writeSidecar(targetDir, bundledHash, hashTree(targetDir));
230
+ return 'synced';
231
+ }
232
+
233
+ // 把 `presets/` 下每个 preset 目录同步进目标发现根。
234
+ export function syncBundledPresets(targetRoot) {
235
+ const result = { synced: [], current: [], userModified: [], failed: [] };
236
+ const sourceRoot = bundledPresetsRoot();
237
+ mkdirSync(targetRoot, { recursive: true });
238
+ if (existsSync(sourceRoot)) {
239
+ for (const entry of readdirSync(sourceRoot)) {
240
+ const source = join(sourceRoot, entry);
241
+ if (!statSync(source).isDirectory()) continue;
242
+ const id = basename(source);
243
+ try {
244
+ const outcome = syncOnePreset(source, join(targetRoot, id));
245
+ if (outcome === 'synced') result.synced.push(id);
246
+ else if (outcome === 'current') result.current.push(id);
247
+ else result.userModified.push(id);
248
+ } catch (error) {
249
+ result.failed.push({ id, error: error instanceof Error ? error.message : String(error) });
250
+ }
251
+ }
252
+ }
253
+ return result;
254
+ }
@@ -0,0 +1,46 @@
1
+ // lib/core/prices.mjs — 模型单价表(纯数据)。
2
+ // 来源:docs/measured-params.md / docs/cost-closed-loop-design.md(2026-08-24 实测)。
3
+ // 口径:估算、非精确计费;A 级为「单次均价」、B 级为「五段单价」(元/千 token)。
4
+ // 可被 A″ / F 数据层复用(同源同表,不散落魔法数)。
5
+
6
+ // A 级:模型 → 单次均价(元/次)。同规模派发实测:flash 0.0039、继承父(pro)0.0104。
7
+ export const MODEL_AVG_COST = Object.freeze({
8
+ 'deepseek-v4-flash': 0.0039,
9
+ 'deepseek-v4-pro': 0.0104,
10
+ });
11
+
12
+ // B 级:模型 → 五段单价(元/千 token)。当前仅有单次均价实测,五段单价先按
13
+ // 均价占位(input/output 同价),保留键结构供未来实测回填;cache 段缺数据记 null。
14
+ export const MODEL_USAGE_PRICES = Object.freeze({
15
+ 'deepseek-v4-flash': Object.freeze({
16
+ inputTokens: null,
17
+ outputTokens: null,
18
+ cacheReadTokens: null,
19
+ cacheWriteTokens: null,
20
+ reasoningTokens: null,
21
+ avgCost: MODEL_AVG_COST['deepseek-v4-flash'],
22
+ }),
23
+ 'deepseek-v4-pro': Object.freeze({
24
+ inputTokens: null,
25
+ outputTokens: null,
26
+ cacheReadTokens: null,
27
+ cacheWriteTokens: null,
28
+ reasoningTokens: null,
29
+ avgCost: MODEL_AVG_COST['deepseek-v4-pro'],
30
+ }),
31
+ });
32
+
33
+ // 模型名归一:常见别名/前缀统一到价格键;未知返回 undefined(fail-soft 不猜价)。
34
+ export function priceKeyFor(model) {
35
+ if (typeof model !== 'string' || model === '') return undefined;
36
+ const m = model.trim().toLowerCase();
37
+ if (MODEL_AVG_COST[m] !== undefined) return m;
38
+ if (m.includes('flash')) return 'deepseek-v4-flash';
39
+ if (m.includes('pro')) return 'deepseek-v4-pro';
40
+ return undefined;
41
+ }
42
+
43
+ export function avgCostFor(model) {
44
+ const key = priceKeyFor(model);
45
+ return key === undefined ? undefined : MODEL_AVG_COST[key];
46
+ }