dsh-agentone 0.5.44 → 1.0.2

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/README.md CHANGED
@@ -63,6 +63,29 @@ lark-cli / ccpg-cli 状态探测。
63
63
  每个会话基线加载,project 级指令优先);幂等不重复注入,用户已有语言
64
64
  偏好或删除标记块后不再介入(本机只做一次决策,记录于
65
65
  `$DSH_HOME/agentone/language-default.json`)。
66
+ - **飞书云盘资料夹(侧边栏 tab)**:注册 better-sidebar「飞书云盘」页签,
67
+ iframe 直嵌飞书网页版(飞书不设 frame-ancestors,spike 实测),页面交互
68
+ 全部是飞书官方 Web UI。寻址(`lib/drive.js`):打开时用 lark-cli 在
69
+ 「我的空间」根目录找到(没有则创建)`AgentOne` 文件夹并缓存 token
70
+ (改名不影响、被删自动重建);设置页「云盘」tab 支持初始化/重新定位/
71
+ 粘贴链接收养其他文件夹(`POST /agentone/api/drive/home/resolve`)。
72
+ 独立 dsh 未装 better-sidebar 时静默不注册该页签。
73
+ 内容变化时自动刷新:服务端对根清单算指纹(`GET /agentone/api/drive/rev`,10s 节流),
74
+ 页签 ready 后每 8s 轮询,指纹变了才重挂载 iframe。用户正在云盘里浏览时不硬刷新
75
+ (重载会回到根视图):顶部弹蓝色横幅「云盘内容有更新 / 立即刷新」,右下角常驻
76
+ 「刷新」胶囊同步变蓝;焦点离开云盘后下一拍自动应用。点胶囊/横幅立即刷新并重新寻址。
77
+ - **启用云盘(默认启用)**:设置页「云盘」tab 的「启用云盘」开关。AgentOne 文件夹是
78
+ 用户与 AI **共享的工作目录**——AI 可以读取使用里面的资料,也可以把飞书云文档存进去。
79
+ 插件做两件事(`lib/archive.js`):在 `$DSH_HOME/AGENTS.md` 注入受标记管理的指令块
80
+ (每会话基线加载,**只告知能力**——可以查/存、在哪查/存;目标指向 `drive.json` 而
81
+ 不写 token),并把技能装到 `$DSH_HOME/skills/agentone-drive/`
82
+ (`SKILL.md` + `scripts/drive.mjs`:`list` 查看内容(只读不创建)、按「项目或应用」
83
+ 查找或创建子文件夹、重复保存同一文档不重复移动、缺权限时给中文自助指引)。
84
+ 默认启用,只有用户明确停用过才关闭(落盘 `enabled:false`);停用时撤销指令块与
85
+ 技能目录。同名技能目录不是本插件创建时拒绝覆盖,开关状态也不改动;历史版本的
86
+ `agentone-drive-archive` 目录带归属标记时升级自动清理。
87
+ **不承诺「自动保存」**——插件做不到替模型执行,是否读/存由模型按任务判断;
88
+ 用户可见文案统一用「可以读取 / 可以存进」,不用「自动保存」「归档」等说法。
66
89
  - **首启引导**:dsh 首次启动弹出平台登录引导(order -50,早于官方
67
90
  「添加 API Key」),登录一次即跳过官方密钥引导;暗色主题适配。
68
91
 
package/lib/archive.js ADDED
@@ -0,0 +1,322 @@
1
+ // 「启用云盘」:把飞书云盘的 AgentOne 文件夹变成用户与 AI 共享的工作目录
2
+ // (设置页「云盘」开关,默认启用)。AI 可以**读取使用**里面的资料,也可以把
3
+ // 飞书云文档存进去。
4
+ //
5
+ // 措辞红线(产品决策):不存在「AI 自动保存」——插件做不到替模型执行保存;全局
6
+ // 指令只**告知能力**(可以读、可以存)与入口,是否执行由模型在任务中判断。
7
+ // 所以用户可见文案一律用「可以读取 / 可以存进」,不用「自动保存/自动归档」。
8
+ //
9
+ // 分两个半区配合,缺一不可:
10
+ // A. 能力告知 —— 在 $DSH_HOME/AGENTS.md 注入受标记管理的指令块(dsh 官方
11
+ // agent-instructions 机制每会话基线加载),告诉模型「可以查/存、在哪查/存」。
12
+ // 块里只写「目标由 drive.json 决定」,不写 token —— 用户重新定位/收养
13
+ // 文件夹后不必重写指令。
14
+ // B. 操作手册 —— 把技能(SKILL.md + scripts/drive.mjs)装到
15
+ // $DSH_HOME/skills/agentone-drive/,承载具体命令(列目录/读取/保存)、
16
+ // 子文件夹规则与重复保存的判断;指令块只指路,细节按需加载。
17
+ //
18
+ // 开关默认**启用**:这是产品默认行为,普通用户不必先找到并看懂开关。只有用户
19
+ // 明确停用过才关闭(落盘 enabled:false);启用 → 注入指令 + 装技能;停用 → 撤销两者。
20
+ // 同名技能目录不是本插件创建时(foreign)拒绝安装,绝不覆盖用户文件;
21
+ // 历史版本装过的旧技能目录(agentone-drive-archive)带归属标记时顺带清理。
22
+ import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
23
+ import { dirname, join, resolve } from 'node:path';
24
+
25
+ import { driveCachePath } from './drive.js';
26
+ import { resolveDshHome, storeDir } from './store.js';
27
+
28
+ export const ARCHIVE_MARKER_BEGIN = '<!-- dsh-agentone:drive-archive begin -->';
29
+ export const ARCHIVE_MARKER_END = '<!-- dsh-agentone:drive-archive end -->';
30
+ export const ARCHIVE_SKILL_SLUG = 'agentone-drive';
31
+ export const ARCHIVE_SKILL_VERSION = '1.2.0';
32
+ /** 历史版本用过的技能目录名:升级时带归属标记的顺带清理,避免技能列表出现新旧两份。 */
33
+ export const LEGACY_SKILL_SLUGS = ['agentone-drive-archive'];
34
+ const PREF_FILE = 'archive.json';
35
+ /** 技能目录归属标记:只有带这个文件的同名目录才允许被本插件改写/删除。 */
36
+ const MANAGED_FILE = '.agentone-managed.json';
37
+ const SKILL_SOURCE_DIR = new URL('../skills/agentone-drive/', import.meta.url);
38
+
39
+ function prefPath(config) {
40
+ return join(storeDir(config), PREF_FILE);
41
+ }
42
+
43
+ async function statOrNull(path) {
44
+ try {
45
+ return await lstat(path);
46
+ } catch (error) {
47
+ if (error?.code === 'ENOENT') return null;
48
+ throw error;
49
+ }
50
+ }
51
+
52
+ async function readTextOrNull(path) {
53
+ try {
54
+ return await readFile(path, 'utf8');
55
+ } catch (error) {
56
+ if (error?.code === 'ENOENT') return null;
57
+ throw error;
58
+ }
59
+ }
60
+
61
+ async function writeFileAtomic(path, text, { mode = 0o644, dirMode = 0o755 } = {}) {
62
+ await mkdir(dirname(path), { recursive: true, mode: dirMode });
63
+ // 随机后缀:同毫秒内两次写同一路径时 tmp 名不撞(互斥链之外的兜底)
64
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
65
+ await writeFile(tmp, text, { mode, flag: 'w' });
66
+ await rename(tmp, path);
67
+ }
68
+
69
+ /**
70
+ * 归档写入互斥链:启动收敛(syncArchiveFeature)与设置页切换(setArchiveEnabled)
71
+ * 可能并发,两边都会写 AGENTS.md 与技能文件;不串行化会出现同名 tmp 互撞
72
+ * (rename ENOENT)和「一半新一半旧」的中间态。与 store.js 的凭据写链同思路。
73
+ */
74
+ let archiveWriteChain = Promise.resolve();
75
+ function serialized(operation) {
76
+ const next = archiveWriteChain.then(operation, operation);
77
+ archiveWriteChain = next.catch(() => {});
78
+ return next;
79
+ }
80
+
81
+ /** 读「启用云盘」开关。默认**启用**(普通用户不必先找到并看懂开关);只有明确
82
+ * 停用过(落盘 `enabled: false`)才关闭,文件缺失/损坏都按默认启用处理。 */
83
+ export async function loadArchivePref(config) {
84
+ try {
85
+ const data = JSON.parse(await readFile(prefPath(config), 'utf8'));
86
+ return { enabled: data?.enabled !== false, updated_at: data?.updated_at || null };
87
+ } catch {
88
+ return { enabled: true, updated_at: null };
89
+ }
90
+ }
91
+
92
+ export function saveArchivePref(config, { enabled }) {
93
+ const body = {
94
+ schema_version: 1,
95
+ enabled: enabled === true,
96
+ updated_at: new Date().toISOString(),
97
+ };
98
+ return writeFileAtomic(prefPath(config), `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
99
+ }
100
+
101
+ /**
102
+ * 归档功能的作用域根:显式 dshHome(多 profile / 测试)优先,否则 $DSH_HOME。
103
+ * 指令块与技能都必须落在 dsh 真正加载指令/技能的那棵树里;显式传入是为了让
104
+ * storeDir 被改写的调用方(含测试)不会误改真实用户目录。
105
+ */
106
+ function archiveHome(config) {
107
+ return config?.dshHome || resolveDshHome();
108
+ }
109
+
110
+ /**
111
+ * 作用域一致性守卫:开关/缓存落在 storeDir,指令块/技能落在 dsh 主目录——两者
112
+ * 必须是同一棵树(storeDir = <dshHome>/agentone)。调用方只隔离 storeDir 而不改
113
+ * dshHome(测试与隔离环境就是这样)时,按全局开关去写真实主目录只会污染用户
114
+ * 文件(事故:默认启用后,某测试进程把真实 ~/.dsh/AGENTS.md 的指令块写成了
115
+ * 临时目录里的 drive.json 路径)。此类跨树写入一律拒绝。
116
+ */
117
+ function scopeConsistent(config) {
118
+ return resolve(storeDir(config)) === resolve(join(archiveHome(config), 'agentone'));
119
+ }
120
+
121
+ /**
122
+ * 在进入异步链**之前**同步冻结作用域(dshHome + storeDir 都落成显式值)。
123
+ * 链体可能在调用方还原 DSH_HOME 之后才执行(fire-and-forget 启动收敛正如此);
124
+ * 若链内再解析 env,就会出现「守卫按临时目录放行、写入按真实目录落盘」的撕裂
125
+ * ——第二次事故正是这样把真实 AGENTS.md 写成了另一个临时路径。冻结后守卫与
126
+ * 写入看到的是同一组确定性路径。
127
+ */
128
+ function freezeScope(config) {
129
+ return { ...config, dshHome: archiveHome(config), storeDir: storeDir(config) };
130
+ }
131
+
132
+ /** 云盘技能的本机目录(与平台技能同一 skills 根,dsh 会话据此加载技能)。 */
133
+ export function archiveSkillDir(config) {
134
+ return join(archiveHome(config), 'skills', ARCHIVE_SKILL_SLUG);
135
+ }
136
+
137
+ /**
138
+ * AGENTS.md 里的「启用云盘」规则块(只指路,不写 token)。
139
+ * 注意措辞口径(产品决策):不写「自动保存」这类承诺——插件做不到替模型执行;
140
+ * 指令只**告知能力**(可以读、可以存)与入口,是否执行由模型在任务中判断。
141
+ */
142
+ export function archiveInstructionBlock({ driveCachePath: cachePath }) {
143
+ return [
144
+ ARCHIVE_MARKER_BEGIN,
145
+ '- 云盘(dsh-agentone 已启用):飞书云盘的 AgentOne 文件夹是本机与 AI 共享的工作目录,',
146
+ ' 你可以读取使用里面的资料,也可以把飞书云文档(文档 / 表格 / 多维表格 / 幻灯片)',
147
+ ' 存进去,子文件夹按「项目或应用」自动创建。',
148
+ '- 需要参考资料、或用户提到云盘里的资料时,可用 agentone-drive 技能查看与读取;',
149
+ ' 交付文档或用户要求保存/整理文档时,可用它存进对应的项目文件夹。',
150
+ `- 文件夹地址以 \`${cachePath}\` 的 \`agentone_folder\` 为准;读不到就先让用户在`,
151
+ ' 「设置 → 云盘」点「初始化 / 重新定位」。命令与重复保存的判断见 agentone-drive 技能。',
152
+ '- 到「设置 → 云盘」停用开关即可取消。',
153
+ ARCHIVE_MARKER_END,
154
+ ].join('\n');
155
+ }
156
+
157
+ /**
158
+ * 按开关同步 AGENTS.md 指令块。永不触碰标记块以外的内容;标记块残缺(只有
159
+ * 头或只有尾)时宁可不动,避免把用户文件截断。
160
+ */
161
+ export async function applyArchiveInstructions({ agentsPath, cachePath, enabled, log = console }) {
162
+ const content = (await readTextOrNull(agentsPath)) ?? '';
163
+ const begin = content.indexOf(ARCHIVE_MARKER_BEGIN);
164
+ const end = content.indexOf(ARCHIVE_MARKER_END);
165
+ const hasBegin = begin >= 0;
166
+ const hasEnd = end >= 0;
167
+ if (hasBegin !== hasEnd || (hasBegin && end < begin)) {
168
+ log.warn?.(`[dsh-agentone] ${agentsPath} 的归档指令标记不完整,已跳过(请手动清理该块)`);
169
+ return 'malformed';
170
+ }
171
+ if (!enabled) {
172
+ if (!hasBegin) return 'absent';
173
+ const stripped = content.slice(0, begin) + content.slice(end + ARCHIVE_MARKER_END.length);
174
+ const next = stripped.replace(/\n{3,}/g, '\n\n').replace(/^\s*\n/, '').replace(/\s+$/, '');
175
+ await writeFileAtomic(agentsPath, next ? `${next}\n` : '');
176
+ return 'removed';
177
+ }
178
+ const block = archiveInstructionBlock({ driveCachePath: cachePath });
179
+ if (hasBegin) {
180
+ const current = content.slice(begin, end + ARCHIVE_MARKER_END.length);
181
+ if (current === block) return 'present';
182
+ await writeFileAtomic(agentsPath, content.slice(0, begin) + block + content.slice(end + ARCHIVE_MARKER_END.length));
183
+ return 'updated';
184
+ }
185
+ const next = content.trim() ? `${content.replace(/\s+$/, '')}\n\n${block}\n` : `${block}\n`;
186
+ await writeFileAtomic(agentsPath, next);
187
+ return 'applied';
188
+ }
189
+
190
+ /**
191
+ * 安装/更新云盘技能。返回 'installed' | 'current' | 'foreign'。
192
+ * 'foreign' = 同名目录存在但不是本插件创建的(用户自己的技能),拒绝覆盖。
193
+ */
194
+ export async function installArchiveSkill(config, log = console) {
195
+ const dir = archiveSkillDir(config);
196
+ const existing = await statOrNull(dir);
197
+ if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) {
198
+ log.warn?.(`[dsh-agentone] 技能路径 ${dir} 不是普通目录,跳过云盘技能安装`);
199
+ return 'foreign';
200
+ }
201
+ if (existing && !(await statOrNull(join(dir, MANAGED_FILE)))) {
202
+ log.warn?.(`[dsh-agentone] 技能目录 ${dir} 不是本插件创建的,跳过云盘技能安装`);
203
+ return 'foreign';
204
+ }
205
+ const template = await readFile(new URL('SKILL.md', SKILL_SOURCE_DIR), 'utf8');
206
+ const script = await readFile(new URL('scripts/drive.mjs', SKILL_SOURCE_DIR), 'utf8');
207
+ const rendered = template
208
+ .replaceAll('{{DRIVE_SCRIPT}}', join(dir, 'scripts', 'drive.mjs'))
209
+ .replaceAll('{{DRIVE_CACHE}}', driveCachePath(config));
210
+ const files = [
211
+ ['SKILL.md', rendered, 0o644],
212
+ [join('scripts', 'drive.mjs'), script, 0o644],
213
+ [MANAGED_FILE, `${JSON.stringify({
214
+ schema_version: 1, managed_by: 'dsh-agentone', slug: ARCHIVE_SKILL_SLUG, version: ARCHIVE_SKILL_VERSION,
215
+ }, null, 2)}\n`, 0o600],
216
+ ];
217
+ let changed = false;
218
+ for (const [relative, text, mode] of files) {
219
+ const path = join(dir, relative);
220
+ if ((await readTextOrNull(path)) === text) continue;
221
+ await writeFileAtomic(path, text, { mode });
222
+ changed = true;
223
+ }
224
+ return changed ? 'installed' : 'current';
225
+ }
226
+
227
+ /** 删除一个「本插件创建的」技能目录(带归属标记才删)。返回 'removed' | 'absent' | 'foreign'。 */
228
+ async function removeManagedSkillDir(dir, log) {
229
+ const existing = await statOrNull(dir);
230
+ if (!existing) return 'absent';
231
+ if (!existing.isDirectory() || existing.isSymbolicLink() || !(await statOrNull(join(dir, MANAGED_FILE)))) {
232
+ log.warn?.(`[dsh-agentone] 技能目录 ${dir} 不是本插件创建的,跳过云盘技能卸载`);
233
+ return 'foreign';
234
+ }
235
+ await rm(dir, { recursive: true, force: true });
236
+ return 'removed';
237
+ }
238
+
239
+ /** 卸载云盘技能。返回 'removed' | 'absent' | 'foreign'(非本插件目录不动)。 */
240
+ export function removeArchiveSkill(config, log = console) {
241
+ return removeManagedSkillDir(archiveSkillDir(config), log);
242
+ }
243
+
244
+ /** 清理历史版本的旧技能目录(agentone-drive-archive 等):只删带归属标记的。 */
245
+ export async function removeLegacyArchiveSkills(config, log = console) {
246
+ const results = [];
247
+ for (const slug of LEGACY_SKILL_SLUGS) {
248
+ results.push(await removeManagedSkillDir(join(archiveHome(config), 'skills', slug), log));
249
+ }
250
+ return results;
251
+ }
252
+
253
+ function agentsPath(config) {
254
+ return join(archiveHome(config), 'AGENTS.md');
255
+ }
256
+
257
+ /**
258
+ * 切换「启用云盘」开关(设置页)。启用时先装技能再写指令——技能装不上(foreign)
259
+ * 就不改开关、不写指令,避免留下「指令让 AI 用不存在的技能」的半截状态。
260
+ */
261
+ export function setArchiveEnabled(config, enabled, log = console) {
262
+ const scoped = freezeScope(config);
263
+ return serialized(async () => {
264
+ // 跨树写入守卫:storeDir 与 dsh 主目录不一致(隔离环境/测试)时拒绝,见 scopeConsistent 注释
265
+ if (!scopeConsistent(scoped)) {
266
+ log.warn?.('[dsh-agentone] 存储目录与 dsh 主目录不在同一棵树,跳过「启用云盘」开关写入');
267
+ return { ok: false, error: '存储目录与 dsh 主目录不一致,已跳过「启用云盘」的写入;请检查插件配置' };
268
+ }
269
+ const cachePath = driveCachePath(scoped);
270
+ if (enabled) {
271
+ const skill = await installArchiveSkill(scoped, log);
272
+ if (skill === 'foreign') {
273
+ return { ok: false, error: '本机技能目录里已存在同名技能「agentone-drive」,'
274
+ + '且不是本插件创建的,无法自动替换;请先移除或改名后重试' };
275
+ }
276
+ await saveArchivePref(scoped, { enabled: true });
277
+ await removeLegacyArchiveSkills(scoped, log).catch(() => {});
278
+ const instructions = await applyArchiveInstructions({
279
+ agentsPath: agentsPath(scoped), cachePath, enabled: true, log,
280
+ });
281
+ log.info?.(`[dsh-agentone] 已启用云盘(技能 ${skill},指令 ${instructions})`);
282
+ return { ok: true, enabled: true, skill, instructions };
283
+ }
284
+ await saveArchivePref(scoped, { enabled: false });
285
+ const skill = await removeArchiveSkill(scoped, log);
286
+ await removeLegacyArchiveSkills(scoped, log).catch(() => {});
287
+ const instructions = await applyArchiveInstructions({
288
+ agentsPath: agentsPath(scoped), cachePath, enabled: false, log,
289
+ });
290
+ log.info?.(`[dsh-agentone] 已停用云盘(技能 ${skill},指令 ${instructions})`);
291
+ return { ok: true, enabled: false, skill, instructions };
292
+ });
293
+ }
294
+
295
+ /** 启动时按落盘开关收敛状态(用户在别处改过 AGENTS.md / 技能目录也能对齐)。 */
296
+ export function syncArchiveFeature(config, log = console) {
297
+ const scoped = freezeScope(config);
298
+ return serialized(async () => {
299
+ // 跨树写入守卫:隔离环境(storeDir 单独指定、dshHome 是真实主目录)不得写真实
300
+ // 用户的 AGENTS.md / skills;生产配置两者天然同树,不受影响。
301
+ if (!scopeConsistent(scoped)) {
302
+ log.warn?.('[dsh-agentone] 存储目录与 dsh 主目录不在同一棵树,跳过云盘开关收敛');
303
+ return { enabled: false, skill: 'scope_mismatch', instructions: 'scope_mismatch' };
304
+ }
305
+ const pref = await loadArchivePref(scoped);
306
+ // 旧版本装过的技能目录(agentone-drive-archive)一律清理,避免新旧两份并存
307
+ await removeLegacyArchiveSkills(scoped, log).catch(() => {});
308
+ if (!pref.enabled) {
309
+ const skill = await removeArchiveSkill(scoped, log);
310
+ const instructions = await applyArchiveInstructions({
311
+ agentsPath: agentsPath(scoped), cachePath: driveCachePath(scoped), enabled: false, log,
312
+ });
313
+ return { enabled: false, skill, instructions };
314
+ }
315
+ const skill = await installArchiveSkill(scoped, log);
316
+ if (skill === 'foreign') return { enabled: true, skill, instructions: 'skill_conflict' };
317
+ const instructions = await applyArchiveInstructions({
318
+ agentsPath: agentsPath(scoped), cachePath: driveCachePath(scoped), enabled: true, log,
319
+ });
320
+ return { enabled: true, skill, instructions };
321
+ });
322
+ }