Skip to content

Commit f77c50f

Browse files
committed
Version 1.0.3 is expected to be released.
1 parent 72a638c commit f77c50f

8 files changed

Lines changed: 175 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- 提供了在线访问Agent的功能.
66
- 优化了上下文压缩.
7+
- 在工作目录下创建、使用临时技能进行临时性自我进化
78

89
## en
910

assets/default_skills/toolshell/SKILL.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,25 @@
5656
- `*.pem` / `*.key` 私钥文件
5757
- 项目根目录之外的任何路径
5858

59+
## 项目级临时技能 (.toolshell/skills/)
60+
61+
当你需要为**当前工作目录**创建一个专属的、可复用的技能时(例如把一段常用流程固化成工具,或为这个项目沉淀一套操作规范),按以下约定放置,应用会在下次构建上下文时自动加载:
62+
63+
```
64+
<工作目录>/.toolshell/skills/<技能名>/
65+
SKILL.md # 必需:技能说明与使用指南(会注入到系统提示词)
66+
tools.json # 可选:自定义工具定义(OpenAI function 格式)
67+
```
68+
69+
规则:
70+
- **必需** `SKILL.md`,缺失则该目录不会被识别为技能
71+
- `tools.json` 可选;若提供,其中声明的工具会一并注册,仍通过 toolshell 执行并受权限约束
72+
- 这类技能**只在所属工作目录下生效**,恒定处于激活状态,无需用户在技能页手动启用
73+
- 切换到其他工作目录后自动消失,不影响全局技能
74+
- 不要把项目技能放到其他位置(如工作目录根、随意命名的文件夹)——只有 `.toolshell/skills/` 会被扫描
75+
76+
创建技能后,告知用户该技能已就绪、将在新对话或下一轮上下文构建时自动加载。
77+
5978
## 记忆系统
6079

6180
### 存储位置

lib/core/agent/agent_context.dart

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import '../memory/memory_manager.dart';
1414
import '../memory/project_config.dart';
1515
import '../memory/qdrant_service.dart';
1616
import '../search/search_capability.dart';
17+
import '../skill/skill_model.dart';
1718
import '../toolshell/agent_loop.dart';
1819
import '../toolshell/executor.dart';
1920
import '../../providers/database_provider.dart';
@@ -210,9 +211,8 @@ class AgentContextBuilder {
210211
PermissionMiddleware(onPermissionRequest: onPermissionRequest),
211212
];
212213

213-
// ─── 收集 skill 可读路径 ───
214-
final skillsState = _ref.read(skillsProvider);
215-
final activeSkillPaths = (skillsState.valueOrNull ?? [])
214+
// ─── 收集 skill 可读路径 (全局 + 项目级) ───
215+
final activeSkillPaths = _collectAllSkills()
216216
.where((s) => s.isActive && s.path.isNotEmpty)
217217
.map((s) => s.path)
218218
.toList();
@@ -291,6 +291,17 @@ class AgentContextBuilder {
291291

292292
Map<String, String> _lastSourceMapping = {};
293293

294+
/// 合并全局技能与项目级临时技能,供 Agent 运行时统一消费。
295+
///
296+
/// 全局技能来自 [skillsProvider](技能页可管理),项目技能来自
297+
/// [projectSkillsProvider](仅扫描工作目录 `.toolshell/skills/`,恒定激活)。
298+
/// 两者数据源隔离:项目技能只在此处合并挂载,不污染任何全局技能管理 UI。
299+
List<Skill> _collectAllSkills() {
300+
final global = _ref.read(skillsProvider).valueOrNull ?? const [];
301+
final project = _ref.read(projectSkillsProvider).valueOrNull ?? const [];
302+
return [...global, ...project];
303+
}
304+
294305
Future<List<Map<String, dynamic>>> _loadTools() async {
295306
final tools = <Map<String, dynamic>>[];
296307
final sourceMapping = <String, String>{};
@@ -332,10 +343,9 @@ class AgentContextBuilder {
332343
tools.addAll(sysTools);
333344
}
334345

335-
// 用户技能
346+
// 用户技能 (全局 + 项目级临时技能)
336347
final registry = _ref.read(skillRegistryProvider);
337-
final skillsState = _ref.read(skillsProvider);
338-
final skills = skillsState.valueOrNull ?? [];
348+
final skills = _collectAllSkills();
339349
final allSkillNames = skills
340350
.map((s) => '${s.name}(${s.isActive ? "激活" : "未启用"})')
341351
.toList();
@@ -382,9 +392,9 @@ class AgentContextBuilder {
382392

383393
/// 计算当前激活用户技能的轻量签名。
384394
/// 用于判断会话中途技能集合是否变化,决定是否需要刷新 system prompt。
385-
/// 与 [_buildSkillsSection] 的数据来源保持一致。
395+
/// 与 [_buildSkillsSection] 的数据来源保持一致(全局 + 项目级)
386396
String computeSkillSignature() {
387-
final skills = _ref.read(skillsProvider).valueOrNull ?? [];
397+
final skills = _collectAllSkills();
388398
final active =
389399
skills
390400
.where((s) => s.isActive)
@@ -438,8 +448,7 @@ class AgentContextBuilder {
438448
Future<String> _buildSkillsSection() async {
439449
final parts = <String>[];
440450
final registry = _ref.read(skillRegistryProvider);
441-
final skillsState = _ref.read(skillsProvider);
442-
final skills = skillsState.valueOrNull ?? [];
451+
final skills = _collectAllSkills();
443452
for (final skill in skills) {
444453
if (!skill.isActive) continue;
445454
final prompt = await registry.loadSkillPrompt(skill);

lib/core/skill/skill_model.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ class Skill {
1010
final DateTime installedAt;
1111
final int sortIndex;
1212

13+
/// 是否为项目级临时技能 (来自工作目录的 .toolshell/skills/)。
14+
/// 此类技能在所属工作目录下恒定激活,不参与全局启用/停用,
15+
/// 且生命周期跟随工作目录 (切换目录即消失,不可删除/排序)。
16+
final bool isProjectLevel;
17+
1318
const Skill({
1419
required this.id,
1520
required this.name,
@@ -20,6 +25,7 @@ class Skill {
2025
this.isBuiltIn = false,
2126
required this.installedAt,
2227
this.sortIndex = 0,
28+
this.isProjectLevel = false,
2329
});
2430

2531
Skill copyWith({bool? isActive, int? sortIndex}) {
@@ -33,6 +39,7 @@ class Skill {
3339
isBuiltIn: isBuiltIn,
3440
installedAt: installedAt,
3541
sortIndex: sortIndex ?? this.sortIndex,
42+
isProjectLevel: isProjectLevel,
3643
);
3744
}
3845
}

lib/core/skill/skill_registry.dart

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,68 @@ class SkillRegistry {
6464
return skills;
6565
}
6666

67+
/// 项目级临时技能目录的相对路径约定: `<workDir>/.toolshell/skills/`
68+
static const projectSkillsRelPath = '.toolshell/skills';
69+
70+
/// 扫描工作目录下的项目级临时技能 (`.toolshell/skills/<名字>/`)。
71+
///
72+
/// 与全局技能不同:
73+
/// - 不要求 .skill_meta.json (模型/用户手建时通常不会写)
74+
/// - 恒定 isActive=true、isProjectLevel=true
75+
/// - id 用 "project:<目录名>" 前缀,避免与全局技能 id 冲突
76+
/// - 生命周期跟随工作目录,不写入/不修改任何元数据
77+
///
78+
/// [workDir] 为空或目录不存在时返回空列表。
79+
Future<List<Skill>> listProjectSkills(String workDir) async {
80+
if (workDir.isEmpty) return [];
81+
82+
final dir = Directory(p.join(workDir, '.toolshell', 'skills'));
83+
if (!await dir.exists()) return [];
84+
85+
final skills = <Skill>[];
86+
await for (final entity in dir.list()) {
87+
if (entity is! Directory) continue;
88+
final skillMdFile = File(p.join(entity.path, 'SKILL.md'));
89+
if (!await skillMdFile.exists()) continue;
90+
91+
final name = p.basename(entity.path);
92+
93+
// 统计工具数量 (tools.json 可选)
94+
int toolCount = 0;
95+
final toolsJsonFile = File(p.join(entity.path, 'tools.json'));
96+
if (await toolsJsonFile.exists()) {
97+
try {
98+
final tools = jsonDecode(await toolsJsonFile.readAsString()) as List;
99+
toolCount = tools.length;
100+
} catch (_) {}
101+
}
102+
103+
DateTime installedAt = DateTime.now();
104+
try {
105+
installedAt = await skillMdFile
106+
.lastModified()
107+
.timeout(const Duration(seconds: 2));
108+
} catch (_) {}
109+
110+
skills.add(
111+
Skill(
112+
id: 'project:$name',
113+
name: name,
114+
description: '',
115+
path: entity.path,
116+
toolCount: toolCount,
117+
isActive: true, // 项目技能恒定激活
118+
installedAt: installedAt,
119+
sortIndex: 0,
120+
isProjectLevel: true,
121+
),
122+
);
123+
}
124+
125+
skills.sort((a, b) => a.name.compareTo(b.name));
126+
return skills;
127+
}
128+
67129
/// 从 ZIP 文件导入技能
68130
Future<Skill> importFromZip(String zipPath) async {
69131
final zipFile = File(zipPath);

lib/features/chat/chat_provider.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import '../../core/utils/file_processor.dart';
1717
import '../../core/pet/pet_observer.dart';
1818
import '../../providers/database_provider.dart';
1919
import '../../providers/settings_provider.dart';
20+
import '../../providers/skills_provider.dart';
2021
export '../../providers/session_provider.dart';
2122

2223
/// UI 中用于展示的工具调用信息
@@ -386,6 +387,19 @@ class ChatNotifier extends StateNotifier<ChatState> {
386387
];
387388
}
388389

390+
// ─── 重扫项目级临时技能目录,感知中途变化 ───
391+
// 模型可能在上一轮用 toolshell_write 往工作目录的 .toolshell/skills/
392+
// 写入了新的项目级技能。这里强制重建 projectSkillsProvider 让其重扫磁盘,
393+
// 使新技能的工具与 SKILL.md 在本轮就能生效。
394+
// 项目技能与全局技能数据源隔离,重扫不影响全局技能管理 UI。
395+
// 技能集合未变时,下方的签名比对会保持 prompt cache,无额外开销。
396+
try {
397+
_ref.invalidate(projectSkillsProvider);
398+
await _ref.read(projectSkillsProvider.future);
399+
} catch (e) {
400+
print('[SKILL] 重扫项目技能失败(忽略): $e');
401+
}
402+
389403
// ─── 使用 AgentContext 构建执行环境 ───
390404
final contextBuilder = AgentContextBuilder(_ref);
391405
final agentContext = await contextBuilder.build(

lib/features/skills/skills_page.dart

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,9 @@ class _SkillCardTile extends ConsumerWidget {
189189
Row(
190190
children: [
191191
Icon(
192-
skill.isBuiltIn ? Icons.lock : Icons.extension,
192+
skill.isProjectLevel
193+
? Icons.folder_special
194+
: (skill.isBuiltIn ? Icons.lock : Icons.extension),
193195
size: 18,
194196
color: skill.isActive ? colorScheme.primary : Colors.grey,
195197
),
@@ -204,7 +206,25 @@ class _SkillCardTile extends ConsumerWidget {
204206
overflow: TextOverflow.ellipsis,
205207
),
206208
),
207-
if (skill.isBuiltIn)
209+
if (skill.isProjectLevel)
210+
Container(
211+
padding: const EdgeInsets.symmetric(
212+
horizontal: 6,
213+
vertical: 2,
214+
),
215+
decoration: BoxDecoration(
216+
color: colorScheme.tertiaryContainer,
217+
borderRadius: BorderRadius.circular(4),
218+
),
219+
child: Text(
220+
'项目',
221+
style: TextStyle(
222+
fontSize: 10,
223+
color: colorScheme.onTertiaryContainer,
224+
),
225+
),
226+
)
227+
else if (skill.isBuiltIn)
208228
Container(
209229
padding: const EdgeInsets.symmetric(
210230
horizontal: 6,
@@ -246,7 +266,7 @@ class _SkillCardTile extends ConsumerWidget {
246266
children: [
247267
Switch(
248268
value: skill.isActive,
249-
onChanged: skill.isBuiltIn
269+
onChanged: (skill.isBuiltIn || skill.isProjectLevel)
250270
? null
251271
: (_) => ref
252272
.read(skillsProvider.notifier)
@@ -259,14 +279,14 @@ class _SkillCardTile extends ConsumerWidget {
259279
visualDensity: VisualDensity.compact,
260280
onPressed: () => _showSkillMd(context, ref),
261281
),
262-
if (!skill.isBuiltIn)
282+
if (!skill.isBuiltIn && !skill.isProjectLevel)
263283
IconButton(
264284
icon: const Icon(Icons.edit_outlined, size: 18),
265285
tooltip: context.s.skillsEditDesc,
266286
visualDensity: VisualDensity.compact,
267287
onPressed: () => _editDescription(context, ref),
268288
),
269-
if (!skill.isBuiltIn)
289+
if (!skill.isBuiltIn && !skill.isProjectLevel)
270290
IconButton(
271291
icon: const Icon(Icons.delete_outline, size: 18),
272292
tooltip: context.s.commonDelete,

lib/providers/skills_provider.dart

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
22

33
import '../core/skill/skill_model.dart';
44
import '../core/skill/skill_registry.dart';
5+
import '../features/chat/chat_provider.dart';
56
import 'settings_provider.dart';
67

78
/// 技能注册表 Provider
@@ -11,7 +12,11 @@ final skillRegistryProvider = Provider<SkillRegistry>((ref) {
1112
return SkillRegistry(skillsPath: skillsPath);
1213
});
1314

14-
/// 技能列表 Provider
15+
/// 全局技能列表 Provider
16+
///
17+
/// 仅包含用户安装的全局技能 (应用支持目录/Skills)。
18+
/// 不含项目级临时技能 —— 后者由 [projectSkillsProvider] 单独管理,
19+
/// 避免污染全局技能管理 UI (技能页、对话框技能栏、在线服务技能选择)。
1520
final skillsProvider = AsyncNotifierProvider<SkillsNotifier, List<Skill>>(
1621
SkillsNotifier.new,
1722
);
@@ -66,3 +71,25 @@ class SkillsNotifier extends AsyncNotifier<List<Skill>> {
6671
await _registry.reorder(ordered.map((s) => s.id).toList());
6772
}
6873
}
74+
75+
/// 项目级临时技能 Provider
76+
///
77+
/// 扫描当前工作目录的 `.toolshell/skills/`,与全局技能完全隔离。
78+
/// 这类技能恒定激活、生命周期跟随工作目录,仅供 Agent 运行时挂载,
79+
/// 不出现在任何全局技能管理 UI 中。
80+
///
81+
/// 切换工作目录时自动重建。中途新增技能可由消费方 invalidate 触发重扫。
82+
final projectSkillsProvider =
83+
AsyncNotifierProvider<ProjectSkillsNotifier, List<Skill>>(
84+
ProjectSkillsNotifier.new,
85+
);
86+
87+
class ProjectSkillsNotifier extends AsyncNotifier<List<Skill>> {
88+
@override
89+
Future<List<Skill>> build() async {
90+
final registry = ref.watch(skillRegistryProvider);
91+
// 订阅工作目录:切换目录时项目级技能列表自动重建
92+
final workDir = ref.watch(workingDirectoryProvider);
93+
return registry.listProjectSkills(workDir);
94+
}
95+
}

0 commit comments

Comments
 (0)