Skip to content

Commit d515e44

Browse files
committed
Version 1.0.3 is expected to be released.
1 parent 7092f7d commit d515e44

14 files changed

Lines changed: 454 additions & 69 deletions

assets/default_skills/toolshell/SKILL.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,17 @@
8181

8282
### 1. 创建 (Create)
8383

84-
在工作目录下选一个临时目录搭建技能骨架(推荐 `.toolshell/skills/<技能名>/`,便于即时调试)
84+
在工作目录下的 **staging 目录** `.toolshell/_staging/<技能名>/` 搭建技能骨架
8585
- `SKILL.md`(必需):清晰写明技能用途、触发条件、使用指南
8686
- `tools.json`(可选):若技能需要自定义工具,按 OpenAI function 格式声明
8787

8888
`toolshell_write` 写入这些文件。
8989

90+
> 为什么用 `.toolshell/_staging/`**不是** `.toolshell/skills/`
91+
> `.toolshell/skills/` 会被扫描成"项目级临时技能"并恒定激活——若把 /skill-cti 的产物放那里,
92+
> 它既会作为项目技能加载、装到全局后又会作为全局技能加载,造成**双重加载**(工具名注册两遍)。
93+
> `_staging` 不会被扫描,纯粹用于搭建/自测;装到全局后该目录会被自动清理。
94+
9095
### 2. 测试 (Test)
9196

9297
安装到全局**之前**必须自测,确认技能可用:
@@ -96,19 +101,20 @@
96101

97102
### 3. 安装到全局 (Install)
98103

99-
自测通过后,调用 `toolshell_install_skill`把临时技能目录提升为**全局技能**
104+
自测通过后,调用 `toolshell_install_skill`把 staging 技能目录提升为**全局技能**
100105

101106
```
102-
toolshell_install_skill(source_dir="<技能目录绝对路径>", name="<技能名>")
107+
toolshell_install_skill(source_dir="<staging 技能目录绝对路径>", name="<技能名>")
103108
```
104109

105-
- 全局技能落在应用的 `Skills/` 目录,**不是** `.toolshell/skills/`
106-
- 安装后该技能出现在技能页,可在任意工作目录启用复用
107-
- 安装成功后告知用户技能名、用途,并提示可在技能页管理
110+
- 全局技能落在应用的 `Skills/` 目录,**不是** `.toolshell/`
111+
- 安装后该技能出现在技能页,由用户自行开关,可在任意工作目录复用
112+
- 安装成功后 `.toolshell/_staging/<技能名>/` 会被**自动清理**,不在工作目录留副本
113+
- 安装成功后告知用户技能名、用途,并提示可在技能页管理与开关
108114

109115
注意区分两类技能去向:
110-
- **临时调试 / 仅本目录用** → 放 `.toolshell/skills/`(项目级,跟随工作目录)
111-
- **沉淀为可复用能力** → 用 `toolshell_install_skill` 装到全局 `Skills/`
116+
- **仅本目录长期使用的项目规范** → 放 `.toolshell/skills/`(项目级,跟随工作目录,恒定激活
117+
- **沉淀为全局可复用能力**(/skill-cti 的目标)→ 在 `.toolshell/_staging/` 搭建并自测,再用 `toolshell_install_skill` 装到全局 `Skills/`,由用户开关
112118

113119
## 记忆系统
114120

lib/core/agent/agent_context.dart

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'dart:convert';
2+
import 'dart:io';
23

34
import 'package:flutter/services.dart';
45
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -15,6 +16,7 @@ import '../memory/project_config.dart';
1516
import '../memory/qdrant_service.dart';
1617
import '../search/search_capability.dart';
1718
import '../skill/skill_model.dart';
19+
import '../skill/skill_registry.dart';
1820
import '../toolshell/agent_loop.dart';
1921
import '../toolshell/executor.dart';
2022
import '../../providers/database_provider.dart';
@@ -179,6 +181,15 @@ class AgentContextBuilder {
179181
final capabilities = _collectCapabilities();
180182
final customHandlers = <String, CustomToolHandler>{};
181183

184+
// 技能安装工具 (toolshell_install_skill): 把工作目录里临时做好的技能
185+
// 提升到全局技能库。仅在工作目录模式注册 (其工具定义也只在 toolshell/tools.json)。
186+
if (_ref.read(workingDirectoryProvider).isNotEmpty) {
187+
final registry = _ref.read(skillRegistryProvider);
188+
customHandlers['toolshell_install_skill'] = (args) =>
189+
_installSkill(registry, args);
190+
toolSourceMapping['toolshell_install_skill'] = '元技能:ToolShell';
191+
}
192+
182193
for (final cap in capabilities) {
183194
if (!cap.isActive) continue;
184195
// 注册工具定义
@@ -291,15 +302,109 @@ class AgentContextBuilder {
291302

292303
Map<String, String> _lastSourceMapping = {};
293304

305+
/// 自定义工具 `toolshell_install_skill` 的执行体。
306+
///
307+
/// 把工作目录里临时做好的技能目录 (须直接含 SKILL.md) 提升为全局技能,
308+
/// 落在 `Skills/` 目录、出现在技能页。安装后刷新 [skillsProvider] 让技能页即时更新。
309+
///
310+
/// 安装成功后,若源目录位于 `<工作目录>/.toolshell/` 内(即模型用 /skill-cti 流程
311+
/// 搭建的临时 staging 目录),会删除该源目录——技能从"项目临时"毕业为"全局可复用",
312+
/// 不在工作目录留副本。这样避免同一技能被项目技能扫描器重复加载(双载)。
313+
/// 内容已存入全局 Skills/,删除不丢数据;仅删模型显式传入、且确属工作目录 .toolshell 下的目录。
314+
///
315+
/// 返回 JSON 字符串供模型解析。
316+
Future<String> _installSkill(
317+
SkillRegistry registry,
318+
Map<String, dynamic> args,
319+
) async {
320+
final sourceDir = (args['source_dir'] as String?)?.trim() ?? '';
321+
final name = (args['name'] as String?)?.trim();
322+
if (sourceDir.isEmpty) {
323+
return jsonEncode({
324+
'status': 'error',
325+
'message': '缺少必需参数 source_dir (技能源目录的绝对路径)',
326+
});
327+
}
328+
try {
329+
final skill = await registry.installFromDirectory(
330+
sourceDir,
331+
name: (name != null && name.isNotEmpty) ? name : null,
332+
);
333+
// 刷新技能页数据源,让新技能立即出现在技能管理 UI
334+
_ref.invalidate(skillsProvider);
335+
AppLogger.instance.log(
336+
'[AgentContext] 技能已安装到全局: ${skill.name} (${skill.path})',
337+
);
338+
339+
// 清理 staging 源目录:仅当其位于 <工作目录>/.toolshell/ 内时删除,
340+
// 避免技能在工作目录里残留成无法关闭的项目临时副本(导致双载)。
341+
final cleaned = await _cleanupStagingDir(sourceDir);
342+
if (cleaned) {
343+
_ref.invalidate(projectSkillsProvider);
344+
}
345+
346+
return jsonEncode({
347+
'status': 'ok',
348+
'name': skill.name,
349+
'path': skill.path,
350+
'tool_count': skill.toolCount,
351+
'staging_cleaned': cleaned,
352+
'message': '技能「${skill.name}」已安装到全局技能库,可在技能页管理并在任意工作目录复用'
353+
'${cleaned ? "(工作目录的临时副本已清理)" : ""}。',
354+
});
355+
} catch (e) {
356+
AppLogger.instance.log('[AgentContext] 技能安装失败: $e');
357+
return jsonEncode({'status': 'error', 'message': '安装失败: $e'});
358+
}
359+
}
360+
361+
/// 若 [sourceDir] 位于当前工作目录的 `.toolshell/` 下,则删除它并返回 true;
362+
/// 否则不动并返回 false。用于 /skill-cti 安装后清理临时 staging 目录。
363+
///
364+
/// 边界保护:解析为绝对/规范化路径后,严格校验 sourceDir 在
365+
/// `<工作目录>/.toolshell/` 之内,绝不删除工作目录本身或其外部路径。
366+
Future<bool> _cleanupStagingDir(String sourceDir) async {
367+
try {
368+
final workDir = _ref.read(workingDirectoryProvider);
369+
if (workDir.isEmpty) return false;
370+
371+
final toolshellRoot = p.canonicalize(p.join(workDir, '.toolshell'));
372+
final src = p.canonicalize(sourceDir);
373+
374+
// src 必须严格在 .toolshell/ 之内(且不等于 .toolshell 本身)
375+
final isInside = p.isWithin(toolshellRoot, src);
376+
if (!isInside) return false;
377+
378+
final dir = Directory(src);
379+
if (await dir.exists()) {
380+
await dir.delete(recursive: true);
381+
AppLogger.instance.log('[AgentContext] 已清理 staging 技能目录: $src');
382+
return true;
383+
}
384+
return false;
385+
} catch (e) {
386+
AppLogger.instance.log('[AgentContext] 清理 staging 目录失败(忽略): $e');
387+
return false;
388+
}
389+
}
390+
294391
/// 合并全局技能与项目级临时技能,供 Agent 运行时统一消费。
295392
///
296393
/// 全局技能来自 [skillsProvider](技能页可管理),项目技能来自
297394
/// [projectSkillsProvider](仅扫描工作目录 `.toolshell/skills/`,恒定激活)。
298395
/// 两者数据源隔离:项目技能只在此处合并挂载,不污染任何全局技能管理 UI。
396+
///
397+
/// 去重安全网:项目技能与全局技能同名时丢弃项目版(全局优先)。避免某技能既装到
398+
/// 全局、又在工作目录 `.toolshell/skills/` 留有同名副本时被重复加载(工具名注册两遍、
399+
/// 提示词注入两段)。这是纯内存去重,不删除任何文件。
299400
List<Skill> _collectAllSkills() {
300401
final global = _ref.read(skillsProvider).valueOrNull ?? const [];
301402
final project = _ref.read(projectSkillsProvider).valueOrNull ?? const [];
302-
return [...global, ...project];
403+
final globalNames = global.map((s) => s.name).toSet();
404+
final dedupedProject = project
405+
.where((s) => !globalNames.contains(s.name))
406+
.toList();
407+
return [...global, ...dedupedProject];
303408
}
304409

305410
Future<List<Map<String, dynamic>>> _loadTools() async {

lib/core/skill/skill_registry.dart

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,68 @@ class SkillRegistry {
194194
return skill;
195195
}
196196

197+
/// 从普通目录安装技能到全局技能库 (应用支持目录/Skills 或设置指定目录)。
198+
///
199+
/// 用于把工作目录里临时做好的技能 (如 `.toolshell/skills/<名字>/`) "提升"为
200+
/// 全局技能:递归复制目录内容到 `Skills/<名字>/`,并补写 `.skill_meta.json`
201+
/// 与 [importFromZip] 共享同一套元数据与加载逻辑,安装后即出现在技能页。
202+
///
203+
/// [sourceDir] 源技能目录,必须直接包含 SKILL.md。
204+
/// [name] 可选的目标技能名 (默认取源目录名)。
205+
/// 已存在同名全局技能目录时覆盖。
206+
Future<Skill> installFromDirectory(String sourceDir, {String? name}) async {
207+
final src = Directory(sourceDir);
208+
if (!await src.exists()) {
209+
throw Exception('源目录不存在: $sourceDir');
210+
}
211+
final srcSkillMd = File(p.join(src.path, 'SKILL.md'));
212+
if (!await srcSkillMd.exists()) {
213+
throw Exception('源目录缺少 SKILL.md,无法识别为技能: $sourceDir');
214+
}
215+
216+
final skillName = (name != null && name.trim().isNotEmpty)
217+
? name.trim()
218+
: p.basename(src.path);
219+
final skillId = _uuid.v4();
220+
final skillsDir = await getSkillsDir();
221+
final skillDir = Directory(p.join(skillsDir.path, skillName));
222+
223+
// 已存在同名目录则覆盖
224+
if (await skillDir.exists()) {
225+
await skillDir.delete(recursive: true);
226+
}
227+
await skillDir.create(recursive: true);
228+
229+
// 递归复制源目录内容 (跳过源自带的 .skill_meta.json,下面重写)
230+
await for (final entity in src.list(recursive: true)) {
231+
final rel = p.relative(entity.path, from: src.path);
232+
if (rel == '.skill_meta.json') continue;
233+
final outPath = p.join(skillDir.path, rel);
234+
if (entity is Directory) {
235+
await Directory(outPath).create(recursive: true);
236+
} else if (entity is File) {
237+
await File(outPath).parent.create(recursive: true);
238+
await entity.copy(outPath);
239+
}
240+
}
241+
242+
// 写入元数据
243+
final meta = {
244+
'id': skillId,
245+
'installed_at': DateTime.now().toIso8601String(),
246+
'is_active': true,
247+
'sort_index': await _nextSortIndex(),
248+
};
249+
final metaFile = File(p.join(skillDir.path, '.skill_meta.json'));
250+
await metaFile.writeAsString(jsonEncode(meta));
251+
252+
final skill = await _loadSkillFromDir(skillDir);
253+
if (skill == null) {
254+
throw Exception('技能加载失败');
255+
}
256+
return skill;
257+
}
258+
197259
/// 删除技能
198260
Future<void> remove(String skillId) async {
199261
final skills = await listInstalled();

0 commit comments

Comments
 (0)