autosnippet 2.4.0 → 2.6.0
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/bin/cli.js +35 -0
- package/dashboard/dist/assets/{icons-B5rs8uNb.js → icons-rnn04CvH.js} +100 -85
- package/dashboard/dist/assets/index-BBKa3Dgi.js +195 -0
- package/dashboard/dist/assets/index-DLsECfzW.css +1 -0
- package/dashboard/dist/assets/{react-markdown-Bp8u1wRC.js → react-markdown-CWxUbOf4.js} +1 -1
- package/dashboard/dist/assets/{syntax-highlighter-C6bvFtpx.js → syntax-highlighter-CJ2drQQb.js} +1 -1
- package/dashboard/dist/assets/{vendor-Cky7Jynh.js → vendor-f83ah6cm.js} +13 -13
- package/dashboard/dist/index.html +6 -6
- package/lib/cli/SetupService.js +30 -4
- package/lib/core/gateway/Gateway.js +19 -4
- package/lib/external/ai/AiProvider.js +94 -10
- package/lib/external/mcp/McpServer.js +2 -1
- package/lib/external/mcp/handlers/skill.js +76 -18
- package/lib/external/mcp/tools.js +21 -0
- package/lib/http/HttpServer.js +4 -0
- package/lib/http/routes/search.js +5 -3
- package/lib/http/routes/skills.js +108 -0
- package/lib/infrastructure/audit/AuditStore.js +18 -0
- package/lib/injection/ServiceContainer.js +8 -2
- package/lib/service/chat/ChatAgent.js +281 -33
- package/lib/service/chat/ConversationStore.js +377 -0
- package/lib/service/chat/Memory.js +40 -10
- package/lib/service/chat/tools.js +104 -7
- package/lib/service/skills/EventAggregator.js +187 -0
- package/lib/service/skills/SignalCollector.js +524 -0
- package/lib/service/skills/SkillAdvisor.js +323 -0
- package/package.json +1 -1
- package/dashboard/dist/assets/index-0YzLw2ga.css +0 -1
- package/dashboard/dist/assets/index-B9py3ybr.js +0 -154
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConversationStore — 对话持久化 + 上下文窗口管理
|
|
3
|
+
*
|
|
4
|
+
* 设计:
|
|
5
|
+
* - 每个对话一个 JSONL 文件: .autosnippet/conversations/{id}.jsonl
|
|
6
|
+
* - 索引文件: .autosnippet/conversations/index.json
|
|
7
|
+
* - 按 category 隔离: 'user'(Dashboard) / 'system'(SignalCollector)
|
|
8
|
+
* - Token 预算: 超限时自动生成摘要压缩旧轮次
|
|
9
|
+
* - 静默降级: 持久化失败不影响核心功能
|
|
10
|
+
*
|
|
11
|
+
* Token 计算策略:
|
|
12
|
+
* 采用字符数近似估算 (1 token ≈ 3.5 字符中文 / ≈ 4 字符英文)
|
|
13
|
+
* 简单高效,无需额外依赖
|
|
14
|
+
*
|
|
15
|
+
* 文件结构:
|
|
16
|
+
* .autosnippet/conversations/
|
|
17
|
+
* index.json — 对话元数据索引
|
|
18
|
+
* {id}.jsonl — 每行一条消息 {role, content, ts}
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
import Logger from '../../infrastructure/logging/Logger.js';
|
|
25
|
+
|
|
26
|
+
const DEFAULT_TOKEN_BUDGET = 12000; // ~12K tokens 留给历史, 其余给系统提示词和当前消息
|
|
27
|
+
const CHARS_PER_TOKEN = 3.5; // 近似: 中文 ~3.5 / 英文 ~4 / 取偏保守值
|
|
28
|
+
const MAX_CONVERSATIONS = 100; // 索引最多保留 100 个对话
|
|
29
|
+
const SUMMARY_TARGET_TOKENS = 500; // 压缩后的摘要目标 token 数
|
|
30
|
+
|
|
31
|
+
export class ConversationStore {
|
|
32
|
+
#dir;
|
|
33
|
+
#indexPath;
|
|
34
|
+
#logger;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} projectRoot — 用户项目根目录
|
|
38
|
+
*/
|
|
39
|
+
constructor(projectRoot) {
|
|
40
|
+
this.#dir = path.join(projectRoot, '.autosnippet', 'conversations');
|
|
41
|
+
this.#indexPath = path.join(this.#dir, 'index.json');
|
|
42
|
+
this.#logger = Logger.getInstance();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ═══════════════════════════════════════════════════════
|
|
46
|
+
// 公共 API
|
|
47
|
+
// ═══════════════════════════════════════════════════════
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 创建新对话
|
|
51
|
+
* @param {object} opts
|
|
52
|
+
* @param {'user'|'system'} opts.category — 对话类别
|
|
53
|
+
* @param {string} [opts.title] — 对话标题
|
|
54
|
+
* @returns {string} conversationId
|
|
55
|
+
*/
|
|
56
|
+
create({ category = 'user', title = '' } = {}) {
|
|
57
|
+
const id = crypto.randomUUID();
|
|
58
|
+
const entry = {
|
|
59
|
+
id,
|
|
60
|
+
category,
|
|
61
|
+
title,
|
|
62
|
+
createdAt: new Date().toISOString(),
|
|
63
|
+
updatedAt: new Date().toISOString(),
|
|
64
|
+
messageCount: 0,
|
|
65
|
+
hasSummary: false,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const index = this.#loadIndex();
|
|
69
|
+
index.unshift(entry);
|
|
70
|
+
|
|
71
|
+
// 限制索引大小
|
|
72
|
+
if (index.length > MAX_CONVERSATIONS) {
|
|
73
|
+
const removed = index.splice(MAX_CONVERSATIONS);
|
|
74
|
+
// 清理旧对话文件
|
|
75
|
+
for (const old of removed) {
|
|
76
|
+
this.#deleteConversationFile(old.id);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.#saveIndex(index);
|
|
81
|
+
return id;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 追加消息到对话
|
|
86
|
+
* @param {string} conversationId
|
|
87
|
+
* @param {{ role: string, content: string }} message
|
|
88
|
+
*/
|
|
89
|
+
append(conversationId, message) {
|
|
90
|
+
try {
|
|
91
|
+
fs.mkdirSync(this.#dir, { recursive: true });
|
|
92
|
+
const filePath = this.#conversationPath(conversationId);
|
|
93
|
+
const line = JSON.stringify({
|
|
94
|
+
role: message.role,
|
|
95
|
+
content: message.content,
|
|
96
|
+
ts: new Date().toISOString(),
|
|
97
|
+
});
|
|
98
|
+
fs.appendFileSync(filePath, line + '\n', 'utf-8');
|
|
99
|
+
|
|
100
|
+
// 更新索引
|
|
101
|
+
const index = this.#loadIndex();
|
|
102
|
+
const entry = index.find(e => e.id === conversationId);
|
|
103
|
+
if (entry) {
|
|
104
|
+
entry.updatedAt = new Date().toISOString();
|
|
105
|
+
entry.messageCount = (entry.messageCount || 0) + 1;
|
|
106
|
+
// 用首条用户消息作为标题
|
|
107
|
+
if (!entry.title && message.role === 'user') {
|
|
108
|
+
entry.title = message.content.substring(0, 60);
|
|
109
|
+
}
|
|
110
|
+
this.#saveIndex(index);
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
this.#logger.warn(`[ConversationStore] append failed: ${err.message}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 加载对话历史(带 token 预算控制)
|
|
119
|
+
*
|
|
120
|
+
* 如果历史超出 tokenBudget:
|
|
121
|
+
* - 保留开头的摘要(如有)
|
|
122
|
+
* - 截断中间的旧消息
|
|
123
|
+
* - 保留最新的消息
|
|
124
|
+
*
|
|
125
|
+
* @param {string} conversationId
|
|
126
|
+
* @param {object} [opts]
|
|
127
|
+
* @param {number} [opts.tokenBudget] — token 预算
|
|
128
|
+
* @returns {{ role: string, content: string }[]}
|
|
129
|
+
*/
|
|
130
|
+
load(conversationId, { tokenBudget = DEFAULT_TOKEN_BUDGET } = {}) {
|
|
131
|
+
try {
|
|
132
|
+
const filePath = this.#conversationPath(conversationId);
|
|
133
|
+
if (!fs.existsSync(filePath)) return [];
|
|
134
|
+
|
|
135
|
+
const raw = fs.readFileSync(filePath, 'utf-8').trim();
|
|
136
|
+
if (!raw) return [];
|
|
137
|
+
|
|
138
|
+
const messages = raw.split('\n')
|
|
139
|
+
.filter(Boolean)
|
|
140
|
+
.map(line => {
|
|
141
|
+
try {
|
|
142
|
+
const parsed = JSON.parse(line);
|
|
143
|
+
return { role: parsed.role, content: parsed.content };
|
|
144
|
+
} catch { return null; }
|
|
145
|
+
})
|
|
146
|
+
.filter(Boolean);
|
|
147
|
+
|
|
148
|
+
return this.#fitWithinBudget(messages, tokenBudget);
|
|
149
|
+
} catch {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 对话列表
|
|
156
|
+
* @param {object} [opts]
|
|
157
|
+
* @param {'user'|'system'} [opts.category] — 按类别过滤
|
|
158
|
+
* @param {number} [opts.limit=20]
|
|
159
|
+
* @returns {Array}
|
|
160
|
+
*/
|
|
161
|
+
list({ category, limit = 20 } = {}) {
|
|
162
|
+
const index = this.#loadIndex();
|
|
163
|
+
let results = index;
|
|
164
|
+
if (category) {
|
|
165
|
+
results = results.filter(e => e.category === category);
|
|
166
|
+
}
|
|
167
|
+
return results.slice(0, limit);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 删除对话
|
|
172
|
+
* @param {string} conversationId
|
|
173
|
+
*/
|
|
174
|
+
delete(conversationId) {
|
|
175
|
+
this.#deleteConversationFile(conversationId);
|
|
176
|
+
const index = this.#loadIndex();
|
|
177
|
+
const filtered = index.filter(e => e.id !== conversationId);
|
|
178
|
+
this.#saveIndex(filtered);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 为对话生成压缩摘要(需要 AI)
|
|
183
|
+
* 将旧消息替换为一条 system 摘要消息
|
|
184
|
+
*
|
|
185
|
+
* @param {string} conversationId
|
|
186
|
+
* @param {object} opts
|
|
187
|
+
* @param {object} opts.aiProvider — AI Provider 实例
|
|
188
|
+
* @returns {Promise<boolean>} 是否成功压缩
|
|
189
|
+
*/
|
|
190
|
+
async summarize(conversationId, { aiProvider }) {
|
|
191
|
+
if (!aiProvider) return false;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const filePath = this.#conversationPath(conversationId);
|
|
195
|
+
if (!fs.existsSync(filePath)) return false;
|
|
196
|
+
|
|
197
|
+
const raw = fs.readFileSync(filePath, 'utf-8').trim();
|
|
198
|
+
if (!raw) return false;
|
|
199
|
+
|
|
200
|
+
const messages = raw.split('\n')
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.map(line => { try { return JSON.parse(line); } catch { return null; } })
|
|
203
|
+
.filter(Boolean);
|
|
204
|
+
|
|
205
|
+
if (messages.length < 6) return false; // 太短不需要压缩
|
|
206
|
+
|
|
207
|
+
// 保留最近 4 条消息,压缩其余
|
|
208
|
+
const toSummarize = messages.slice(0, -4);
|
|
209
|
+
const toKeep = messages.slice(-4);
|
|
210
|
+
|
|
211
|
+
const summaryPrompt = `请用 2-3 句话总结以下对话的要点(保留关键决策、用户偏好、操作结果):\n\n${
|
|
212
|
+
toSummarize.map(m => `[${m.role}] ${m.content}`).join('\n').substring(0, 4000)
|
|
213
|
+
}`;
|
|
214
|
+
|
|
215
|
+
const summary = await aiProvider.chat(summaryPrompt, {
|
|
216
|
+
temperature: 0.3,
|
|
217
|
+
maxTokens: 300,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (!summary) return false;
|
|
221
|
+
|
|
222
|
+
// 重写对话文件: 摘要 + 最近消息
|
|
223
|
+
const newMessages = [
|
|
224
|
+
{ role: 'system', content: `[对话摘要] ${summary.trim()}`, ts: new Date().toISOString() },
|
|
225
|
+
...toKeep,
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
fs.writeFileSync(
|
|
229
|
+
filePath,
|
|
230
|
+
newMessages.map(m => JSON.stringify(m)).join('\n') + '\n',
|
|
231
|
+
'utf-8',
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// 更新索引
|
|
235
|
+
const index = this.#loadIndex();
|
|
236
|
+
const entry = index.find(e => e.id === conversationId);
|
|
237
|
+
if (entry) {
|
|
238
|
+
entry.hasSummary = true;
|
|
239
|
+
entry.messageCount = newMessages.length;
|
|
240
|
+
this.#saveIndex(index);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
this.#logger.info(`[ConversationStore] summarized conversation ${conversationId}: ${messages.length} → ${newMessages.length} messages`);
|
|
244
|
+
return true;
|
|
245
|
+
} catch (err) {
|
|
246
|
+
this.#logger.warn(`[ConversationStore] summarize failed: ${err.message}`);
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 清理过期对话
|
|
253
|
+
* @param {object} [opts]
|
|
254
|
+
* @param {number} [opts.maxAgeDays=30] — 超过此天数的对话将被删除
|
|
255
|
+
* @param {'user'|'system'} [opts.category] — 只清理特定类别
|
|
256
|
+
* @returns {{ deleted: number }}
|
|
257
|
+
*/
|
|
258
|
+
cleanup({ maxAgeDays = 30, category } = {}) {
|
|
259
|
+
const index = this.#loadIndex();
|
|
260
|
+
const cutoff = Date.now() - maxAgeDays * 86400000;
|
|
261
|
+
let deleted = 0;
|
|
262
|
+
|
|
263
|
+
const kept = index.filter(entry => {
|
|
264
|
+
if (category && entry.category !== category) return true;
|
|
265
|
+
const updatedAt = new Date(entry.updatedAt).getTime();
|
|
266
|
+
if (updatedAt < cutoff) {
|
|
267
|
+
this.#deleteConversationFile(entry.id);
|
|
268
|
+
deleted++;
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
return true;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (deleted > 0) {
|
|
275
|
+
this.#saveIndex(kept);
|
|
276
|
+
this.#logger.info(`[ConversationStore] cleaned up ${deleted} old conversations`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { deleted };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 估算 token 数
|
|
284
|
+
* @param {string} text
|
|
285
|
+
* @returns {number}
|
|
286
|
+
*/
|
|
287
|
+
estimateTokens(text) {
|
|
288
|
+
if (!text) return 0;
|
|
289
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ═══════════════════════════════════════════════════════
|
|
293
|
+
// 内部方法
|
|
294
|
+
// ═══════════════════════════════════════════════════════
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 将消息列表裁剪到 token 预算内
|
|
298
|
+
* 策略: 保留首条摘要(如有) + 最新消息,丢弃中间旧消息
|
|
299
|
+
*/
|
|
300
|
+
#fitWithinBudget(messages, tokenBudget) {
|
|
301
|
+
if (messages.length === 0) return [];
|
|
302
|
+
|
|
303
|
+
// 计算总 token
|
|
304
|
+
let totalTokens = 0;
|
|
305
|
+
const tokenCounts = messages.map(m => {
|
|
306
|
+
const tokens = this.estimateTokens(m.content);
|
|
307
|
+
totalTokens += tokens;
|
|
308
|
+
return tokens;
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
if (totalTokens <= tokenBudget) return messages;
|
|
312
|
+
|
|
313
|
+
// 超预算 — 保留首条(摘要) + 从末尾往前取
|
|
314
|
+
const result = [];
|
|
315
|
+
let used = 0;
|
|
316
|
+
|
|
317
|
+
// 如果首条是 system 摘要,优先保留
|
|
318
|
+
if (messages[0].role === 'system' && messages[0].content.startsWith('[对话摘要]')) {
|
|
319
|
+
result.push(messages[0]);
|
|
320
|
+
used += tokenCounts[0];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 从末尾往前填充
|
|
324
|
+
const tail = [];
|
|
325
|
+
for (let i = messages.length - 1; i >= (result.length > 0 ? 1 : 0); i--) {
|
|
326
|
+
if (used + tokenCounts[i] > tokenBudget) break;
|
|
327
|
+
tail.unshift(messages[i]);
|
|
328
|
+
used += tokenCounts[i];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 如果丢弃了消息,插入提示
|
|
332
|
+
const keptFromStart = result.length;
|
|
333
|
+
const keptFromEnd = tail.length;
|
|
334
|
+
const dropped = messages.length - keptFromStart - keptFromEnd;
|
|
335
|
+
|
|
336
|
+
if (dropped > 0) {
|
|
337
|
+
result.push({
|
|
338
|
+
role: 'system',
|
|
339
|
+
content: `[上下文截断] 省略了 ${dropped} 条较早的消息以适应上下文窗口。`,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
result.push(...tail);
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
#conversationPath(id) {
|
|
348
|
+
return path.join(this.#dir, `${id}.jsonl`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#deleteConversationFile(id) {
|
|
352
|
+
try {
|
|
353
|
+
const filePath = this.#conversationPath(id);
|
|
354
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
355
|
+
} catch { /* ignore */ }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#loadIndex() {
|
|
359
|
+
try {
|
|
360
|
+
if (fs.existsSync(this.#indexPath)) {
|
|
361
|
+
return JSON.parse(fs.readFileSync(this.#indexPath, 'utf-8'));
|
|
362
|
+
}
|
|
363
|
+
} catch { /* corrupt — reset */ }
|
|
364
|
+
return [];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#saveIndex(index) {
|
|
368
|
+
try {
|
|
369
|
+
fs.mkdirSync(this.#dir, { recursive: true });
|
|
370
|
+
fs.writeFileSync(this.#indexPath, JSON.stringify(index, null, 2), 'utf-8');
|
|
371
|
+
} catch (err) {
|
|
372
|
+
this.#logger.warn(`[ConversationStore] index save failed: ${err.message}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export default ConversationStore;
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Memory —
|
|
2
|
+
* Memory — 跨对话轻量记忆(带 source 隔离 + 去重)
|
|
3
3
|
*
|
|
4
4
|
* 设计:
|
|
5
5
|
* - JSONL 文件存储,每行一条记忆
|
|
6
6
|
* - 支持 TTL 自动过期
|
|
7
7
|
* - 上限 maxEntries,超出时截断旧条目
|
|
8
8
|
* - 读写均做静默降级(Memory 是增强,不是核心路径)
|
|
9
|
+
* - source 标签: 'user'(用户对话) / 'system'(SignalCollector 等后台)
|
|
10
|
+
* - 去重: 相同 type+content 的记忆不重复写入
|
|
9
11
|
*
|
|
10
12
|
* 记忆类型:
|
|
11
13
|
* - preference: 用户偏好("我们不用 singleton"、"以后用 DI")
|
|
12
14
|
* - decision: 关键决策("Network 模块审核通过")
|
|
13
15
|
* - context: 项目上下文("主语言是 Swift,使用 SPM")
|
|
14
16
|
*
|
|
17
|
+
* 隔离规则:
|
|
18
|
+
* toPromptSection({ source }) 可按 source 过滤
|
|
19
|
+
* 用户对话只看 source=user 的记忆
|
|
20
|
+
* 系统分析可看全部记忆
|
|
21
|
+
*
|
|
15
22
|
* 文件路径: .autosnippet/memory.jsonl
|
|
16
23
|
*/
|
|
17
24
|
|
|
@@ -35,9 +42,11 @@ export class Memory {
|
|
|
35
42
|
/**
|
|
36
43
|
* 读取最近 N 条记忆,过滤过期项
|
|
37
44
|
* @param {number} [limit=20]
|
|
38
|
-
* @
|
|
45
|
+
* @param {object} [opts]
|
|
46
|
+
* @param {'user'|'system'} [opts.source] — 按 source 过滤
|
|
47
|
+
* @returns {{ ts: string, type: string, content: string, source?: string, ttl?: number }[]}
|
|
39
48
|
*/
|
|
40
|
-
load(limit = 20) {
|
|
49
|
+
load(limit = 20, { source } = {}) {
|
|
41
50
|
try {
|
|
42
51
|
if (!fs.existsSync(this.#filePath)) return [];
|
|
43
52
|
const raw = fs.readFileSync(this.#filePath, 'utf-8').trim();
|
|
@@ -48,6 +57,7 @@ export class Memory {
|
|
|
48
57
|
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
49
58
|
.filter(Boolean)
|
|
50
59
|
.filter(m => !m.ttl || (now - new Date(m.ts).getTime()) < m.ttl * 86400000)
|
|
60
|
+
.filter(m => !source || (m.source || 'user') === source)
|
|
51
61
|
.slice(-limit);
|
|
52
62
|
} catch {
|
|
53
63
|
return [];
|
|
@@ -55,14 +65,27 @@ export class Memory {
|
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
/**
|
|
58
|
-
*
|
|
59
|
-
* @param {{ type: string, content: string, ttl?: number }} entry
|
|
68
|
+
* 追加一条记忆(自动去重)
|
|
69
|
+
* @param {{ type: string, content: string, source?: string, ttl?: number }} entry
|
|
60
70
|
*/
|
|
61
71
|
append(entry) {
|
|
62
72
|
try {
|
|
73
|
+
// 去重: 检查是否已有相同 type+content 的记忆
|
|
74
|
+
const existing = this.load(this.#maxEntries);
|
|
75
|
+
const normalizedContent = (entry.content || '').trim().substring(0, 200);
|
|
76
|
+
const isDuplicate = existing.some(
|
|
77
|
+
m => m.type === entry.type && m.content === normalizedContent,
|
|
78
|
+
);
|
|
79
|
+
if (isDuplicate) return;
|
|
80
|
+
|
|
63
81
|
const dir = path.dirname(this.#filePath);
|
|
64
82
|
fs.mkdirSync(dir, { recursive: true });
|
|
65
|
-
const line = JSON.stringify({
|
|
83
|
+
const line = JSON.stringify({
|
|
84
|
+
ts: new Date().toISOString(),
|
|
85
|
+
source: entry.source || 'user',
|
|
86
|
+
...entry,
|
|
87
|
+
content: normalizedContent,
|
|
88
|
+
});
|
|
66
89
|
fs.appendFileSync(this.#filePath, line + '\n', 'utf-8');
|
|
67
90
|
this.#compact();
|
|
68
91
|
} catch { /* write failure non-critical */ }
|
|
@@ -70,10 +93,15 @@ export class Memory {
|
|
|
70
93
|
|
|
71
94
|
/**
|
|
72
95
|
* 生成供系统提示词的记忆摘要
|
|
96
|
+
*
|
|
97
|
+
* @param {object} [opts]
|
|
98
|
+
* @param {'user'|'system'} [opts.source] — 只包含指定 source 的记忆
|
|
99
|
+
* - 用户对话建议传 'user' 避免系统分析记忆污染
|
|
100
|
+
* - 后台分析可传 undefined 获取全部
|
|
73
101
|
* @returns {string}
|
|
74
102
|
*/
|
|
75
|
-
toPromptSection() {
|
|
76
|
-
const memories = this.load();
|
|
103
|
+
toPromptSection({ source } = {}) {
|
|
104
|
+
const memories = this.load(20, { source });
|
|
77
105
|
if (memories.length === 0) return '';
|
|
78
106
|
const lines = memories.map(m => `- [${m.type}] ${m.content}`).join('\n');
|
|
79
107
|
return `\n## 历史记忆\n以下是之前对话中积累的项目偏好和决策,请参考:\n${lines}\n`;
|
|
@@ -81,9 +109,11 @@ export class Memory {
|
|
|
81
109
|
|
|
82
110
|
/**
|
|
83
111
|
* 当前记忆条数
|
|
112
|
+
* @param {object} [opts]
|
|
113
|
+
* @param {'user'|'system'} [opts.source]
|
|
84
114
|
*/
|
|
85
|
-
|
|
86
|
-
return this.load(this.#maxEntries).length;
|
|
115
|
+
size({ source } = {}) {
|
|
116
|
+
return this.load(this.#maxEntries, { source }).length;
|
|
87
117
|
}
|
|
88
118
|
|
|
89
119
|
/**
|
|
@@ -49,14 +49,16 @@
|
|
|
49
49
|
* │ 30. rebuild_index 向量索引重建 │
|
|
50
50
|
* │ 31. query_audit_log 审计日志查询 │
|
|
51
51
|
* └─────────────────────────────────────────────────────┘
|
|
52
|
-
* ┌─── Skills & Bootstrap (
|
|
52
|
+
* ┌─── Skills & Bootstrap (4) ─────────────────────────┐
|
|
53
53
|
* │ 32. load_skill 加载 Agent Skill 文档 │
|
|
54
|
-
* │ 33.
|
|
54
|
+
* │ 33. create_skill 创建项目级 Skill │
|
|
55
|
+
* │ 34. suggest_skills 推荐创建 Skill │
|
|
56
|
+
* │ 35. bootstrap_knowledge 冷启动知识库初始化 │
|
|
55
57
|
* └─────────────────────────────────────────────────────┘
|
|
56
58
|
* ┌─── 组合工具 (3) ───────────────────────────────────┐
|
|
57
|
-
* │
|
|
58
|
-
* │
|
|
59
|
-
* │
|
|
59
|
+
* │ 36. analyze_code Guard + Recipe 搜索 │
|
|
60
|
+
* │ 37. knowledge_overview 全局知识库概览 │
|
|
61
|
+
* │ 38. submit_with_check 查重 + 提交 │
|
|
60
62
|
* └─────────────────────────────────────────────────────┘
|
|
61
63
|
*/
|
|
62
64
|
|
|
@@ -1167,7 +1169,52 @@ const loadSkill = {
|
|
|
1167
1169
|
};
|
|
1168
1170
|
|
|
1169
1171
|
// ────────────────────────────────────────────────────────────
|
|
1170
|
-
// 33.
|
|
1172
|
+
// 33. create_skill — 创建项目级 Skill
|
|
1173
|
+
// ────────────────────────────────────────────────────────────
|
|
1174
|
+
const createSkillTool = {
|
|
1175
|
+
name: 'create_skill',
|
|
1176
|
+
description: '创建项目级 Skill 文档,写入 .autosnippet/skills/<name>/SKILL.md。Skill 是 Agent 的领域知识增强文档。创建后自动更新编辑器索引。',
|
|
1177
|
+
parameters: {
|
|
1178
|
+
type: 'object',
|
|
1179
|
+
properties: {
|
|
1180
|
+
name: { type: 'string', description: 'Skill 名称(kebab-case,如 my-auth-guide),3-64 字符' },
|
|
1181
|
+
description: { type: 'string', description: 'Skill 一句话描述(写入 frontmatter)' },
|
|
1182
|
+
content: { type: 'string', description: 'Skill 正文内容(Markdown 格式,不含 frontmatter)' },
|
|
1183
|
+
overwrite: { type: 'boolean', description: '如果同名 Skill 已存在,是否覆盖(默认 false)' },
|
|
1184
|
+
},
|
|
1185
|
+
required: ['name', 'description', 'content'],
|
|
1186
|
+
},
|
|
1187
|
+
handler: async (params, ctx) => {
|
|
1188
|
+
const { createSkill } = await import('../../external/mcp/handlers/skill.js');
|
|
1189
|
+
// 根据 ChatAgent 的 source 推断 createdBy
|
|
1190
|
+
const createdBy = ctx?.source === 'system' ? 'system-ai' : 'user-ai';
|
|
1191
|
+
const raw = createSkill(null, { ...params, createdBy });
|
|
1192
|
+
try { return JSON.parse(raw); } catch { return { success: false, error: raw }; }
|
|
1193
|
+
},
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
// ────────────────────────────────────────────────────────────
|
|
1197
|
+
// 34. suggest_skills — 基于使用模式推荐 Skill 创建
|
|
1198
|
+
// ────────────────────────────────────────────────────────────
|
|
1199
|
+
const suggestSkills = {
|
|
1200
|
+
name: 'suggest_skills',
|
|
1201
|
+
description: '基于项目使用模式分析,推荐创建 Skill。分析 Guard 违规频率、Memory 偏好积累、Recipe 分布缺口、候选积压率。返回推荐列表(含 name/description/rationale/priority),可据此直接调用 create_skill 创建。',
|
|
1202
|
+
parameters: {
|
|
1203
|
+
type: 'object',
|
|
1204
|
+
properties: {},
|
|
1205
|
+
required: [],
|
|
1206
|
+
},
|
|
1207
|
+
handler: async (_params, ctx) => {
|
|
1208
|
+
const { SkillAdvisor } = await import('../../service/skills/SkillAdvisor.js');
|
|
1209
|
+
const database = ctx?.container?.get?.('database') || null;
|
|
1210
|
+
const projectRoot = ctx?.projectRoot || process.cwd();
|
|
1211
|
+
const advisor = new SkillAdvisor(projectRoot, { database });
|
|
1212
|
+
return advisor.suggest();
|
|
1213
|
+
},
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
// ────────────────────────────────────────────────────────────
|
|
1217
|
+
// 34. bootstrap_knowledge — 冷启动知识库初始化
|
|
1171
1218
|
// ────────────────────────────────────────────────────────────
|
|
1172
1219
|
const bootstrapKnowledgeTool = {
|
|
1173
1220
|
name: 'bootstrap_knowledge',
|
|
@@ -1396,6 +1443,52 @@ const submitWithCheck = {
|
|
|
1396
1443
|
},
|
|
1397
1444
|
};
|
|
1398
1445
|
|
|
1446
|
+
// ═══════════════════════════════════════════════════════
|
|
1447
|
+
// 元工具: Lazy Tool Schema 按需加载
|
|
1448
|
+
// ═══════════════════════════════════════════════════════
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* get_tool_details — 查询工具的完整参数 schema
|
|
1452
|
+
*
|
|
1453
|
+
* 与 Cline .clinerules 按需加载类似:
|
|
1454
|
+
* System Prompt 只包含工具名+一行描述,LLM 需要调用某个工具前
|
|
1455
|
+
* 先通过此元工具获取完整参数定义,避免 prompt 过长浪费 token。
|
|
1456
|
+
*/
|
|
1457
|
+
const getToolDetails = {
|
|
1458
|
+
name: 'get_tool_details',
|
|
1459
|
+
description: '查询指定工具的完整参数 Schema。在调用不熟悉的工具之前,先用此工具获取参数详情。',
|
|
1460
|
+
parameters: {
|
|
1461
|
+
type: 'object',
|
|
1462
|
+
properties: {
|
|
1463
|
+
toolName: {
|
|
1464
|
+
type: 'string',
|
|
1465
|
+
description: '要查询的工具名称(snake_case)',
|
|
1466
|
+
},
|
|
1467
|
+
},
|
|
1468
|
+
required: ['toolName'],
|
|
1469
|
+
},
|
|
1470
|
+
handler: async ({ toolName }, context) => {
|
|
1471
|
+
const registry = context.container?.get('toolRegistry');
|
|
1472
|
+
if (!registry) return { error: 'ToolRegistry not available' };
|
|
1473
|
+
|
|
1474
|
+
const schemas = registry.getToolSchemas();
|
|
1475
|
+
const found = schemas.find(t => t.name === toolName);
|
|
1476
|
+
if (!found) {
|
|
1477
|
+
const allNames = schemas.map(t => t.name);
|
|
1478
|
+
return {
|
|
1479
|
+
error: `Tool "${toolName}" not found`,
|
|
1480
|
+
availableTools: allNames,
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
return {
|
|
1485
|
+
name: found.name,
|
|
1486
|
+
description: found.description,
|
|
1487
|
+
parameters: found.parameters,
|
|
1488
|
+
};
|
|
1489
|
+
},
|
|
1490
|
+
};
|
|
1491
|
+
|
|
1399
1492
|
export const ALL_TOOLS = [
|
|
1400
1493
|
// 查询类 (8)
|
|
1401
1494
|
searchRecipes,
|
|
@@ -1436,13 +1529,17 @@ export const ALL_TOOLS = [
|
|
|
1436
1529
|
graphImpactAnalysis,
|
|
1437
1530
|
rebuildIndex,
|
|
1438
1531
|
queryAuditLog,
|
|
1439
|
-
// Skills & Bootstrap (
|
|
1532
|
+
// Skills & Bootstrap (4)
|
|
1440
1533
|
loadSkill,
|
|
1534
|
+
createSkillTool,
|
|
1535
|
+
suggestSkills,
|
|
1441
1536
|
bootstrapKnowledgeTool,
|
|
1442
1537
|
// 组合工具 (3) — 减少 ReAct 轮次
|
|
1443
1538
|
analyzeCode,
|
|
1444
1539
|
knowledgeOverview,
|
|
1445
1540
|
submitWithCheck,
|
|
1541
|
+
// 元工具 (1) — Lazy Tool Schema 按需加载
|
|
1542
|
+
getToolDetails,
|
|
1446
1543
|
];
|
|
1447
1544
|
|
|
1448
1545
|
export default ALL_TOOLS;
|