autosnippet 2.8.3 → 2.10.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/README.md +5 -5
- package/bin/cli.js +5 -33
- package/config/constitution.yaml +9 -2
- package/dashboard/dist/assets/{icons-B_Xg4B-s.js → icons-BkT3XrKf.js} +105 -100
- package/dashboard/dist/assets/index-BsB7DzW4.css +1 -0
- package/dashboard/dist/assets/index-DdmQMrJJ.js +155 -0
- package/dashboard/dist/index.html +3 -3
- package/lib/cli/AiScanService.js +13 -11
- package/lib/cli/KnowledgeSyncService.js +343 -0
- package/lib/cli/SetupService.js +9 -27
- package/lib/core/ast/ProjectGraph.js +160 -0
- package/lib/core/gateway/GatewayActionRegistry.js +48 -58
- package/lib/domain/index.js +16 -11
- package/lib/domain/knowledge/KnowledgeEntry.js +351 -0
- package/lib/domain/knowledge/KnowledgeRepository.js +123 -0
- package/lib/domain/knowledge/Lifecycle.js +109 -0
- package/lib/domain/knowledge/index.js +27 -0
- package/lib/domain/knowledge/values/Constraints.js +125 -0
- package/lib/domain/knowledge/values/Content.js +86 -0
- package/lib/domain/knowledge/values/Quality.js +93 -0
- package/lib/domain/knowledge/values/Reasoning.js +69 -0
- package/lib/domain/knowledge/values/Relations.js +168 -0
- package/lib/domain/knowledge/values/Stats.js +87 -0
- package/lib/domain/knowledge/values/index.js +9 -0
- package/lib/external/ai/AiProvider.js +48 -0
- package/lib/external/ai/providers/GoogleGeminiProvider.js +12 -3
- package/lib/external/mcp/McpServer.js +7 -5
- package/lib/external/mcp/handlers/bootstrap/pipeline/orchestrator.js +3 -2
- package/lib/external/mcp/handlers/bootstrap.js +121 -12
- package/lib/external/mcp/handlers/browse.js +77 -73
- package/lib/external/mcp/handlers/candidate.js +29 -276
- package/lib/external/mcp/handlers/guard.js +2 -0
- package/lib/external/mcp/handlers/knowledge.js +205 -0
- package/lib/external/mcp/handlers/skill.js +4 -2
- package/lib/external/mcp/handlers/structure.js +25 -23
- package/lib/external/mcp/handlers/system.js +10 -12
- package/lib/external/mcp/tools.js +125 -138
- package/lib/http/HttpServer.js +4 -8
- package/lib/http/middleware/requestLogger.js +3 -3
- package/lib/http/routes/ai.js +17 -1
- package/lib/http/routes/extract.js +48 -4
- package/lib/http/routes/knowledge.js +246 -0
- package/lib/http/routes/search.js +12 -17
- package/lib/http/routes/skills.js +44 -1
- package/lib/infrastructure/cache/GraphCache.js +143 -0
- package/lib/infrastructure/database/migrations/015_create_token_usage.js +27 -0
- package/lib/infrastructure/database/migrations/016_unified_knowledge_entries.js +395 -0
- package/lib/infrastructure/external/XcodeAutomation.js +187 -103
- package/lib/infrastructure/realtime/RealtimeService.js +14 -2
- package/lib/injection/ServiceContainer.js +164 -63
- package/lib/repository/knowledge/KnowledgeRepository.impl.js +373 -0
- package/lib/repository/token/TokenUsageStore.js +162 -0
- package/lib/service/automation/DirectiveDetector.js +2 -3
- package/lib/service/automation/FileWatcher.js +67 -28
- package/lib/service/automation/XcodeIntegration.js +931 -156
- package/lib/service/automation/handlers/AlinkHandler.js +6 -4
- package/lib/service/automation/handlers/CreateHandler.js +53 -18
- package/lib/service/automation/handlers/GuardHandler.js +183 -20
- package/lib/service/automation/handlers/SearchHandler.js +35 -17
- package/lib/service/chat/AnalystAgent.js +25 -14
- package/lib/service/chat/CandidateGuardrail.js +1 -1
- package/lib/service/chat/ChatAgent.js +280 -48
- package/lib/service/chat/ContextWindow.js +92 -8
- package/lib/service/chat/HandoffProtocol.js +26 -1
- package/lib/service/chat/ProducerAgent.js +11 -9
- package/lib/service/chat/tools.js +298 -194
- package/lib/service/guard/GuardCheckEngine.js +114 -10
- package/lib/service/guard/GuardService.js +59 -48
- package/lib/service/knowledge/ConfidenceRouter.js +159 -0
- package/lib/service/knowledge/KnowledgeFileWriter.js +602 -0
- package/lib/service/knowledge/KnowledgeService.js +725 -0
- package/lib/service/search/SearchEngine.js +92 -19
- package/lib/service/skills/SignalCollector.js +15 -9
- package/lib/service/skills/SkillAdvisor.js +13 -11
- package/lib/service/snippet/SnippetFactory.js +5 -5
- package/lib/service/spm/SpmService.js +119 -18
- package/package.json +1 -1
- package/scripts/install-cursor-skill.js +0 -6
- package/scripts/migrate-md-to-knowledge.mjs +364 -0
- package/skills/autosnippet-analysis/SKILL.md +15 -7
- package/skills/autosnippet-candidates/SKILL.md +6 -6
- package/skills/autosnippet-coldstart/SKILL.md +7 -3
- package/skills/autosnippet-concepts/SKILL.md +7 -6
- package/skills/autosnippet-create/SKILL.md +13 -13
- package/skills/autosnippet-intent/SKILL.md +3 -2
- package/skills/autosnippet-lifecycle/SKILL.md +5 -5
- package/skills/autosnippet-recipes/SKILL.md +16 -4
- package/templates/constitution.yaml +1 -1
- package/templates/copilot-instructions.md +6 -6
- package/templates/recipes-setup/README.md +3 -3
- package/dashboard/dist/assets/index-CkIih2CC.css +0 -1
- package/dashboard/dist/assets/index-Duc8Qk-c.js +0 -197
- package/lib/cli/CandidateSyncService.js +0 -261
- package/lib/cli/SyncService.js +0 -356
- package/lib/domain/candidate/Candidate.js +0 -196
- package/lib/domain/candidate/CandidateRepository.js +0 -107
- package/lib/domain/candidate/Reasoning.js +0 -52
- package/lib/domain/recipe/Recipe.js +0 -421
- package/lib/domain/recipe/RecipeRepository.js +0 -54
- package/lib/domain/types/CandidateStatus.js +0 -52
- package/lib/http/routes/candidates.js +0 -559
- package/lib/http/routes/recipes.js +0 -397
- package/lib/repository/candidate/CandidateRepository.impl.js +0 -230
- package/lib/repository/recipe/RecipeRepository.impl.js +0 -498
- package/lib/service/candidate/CandidateAggregator.js +0 -52
- package/lib/service/candidate/CandidateFileWriter.js +0 -383
- package/lib/service/candidate/CandidateService.js +0 -973
- package/lib/service/recipe/RecipeFileWriter.js +0 -514
- package/lib/service/recipe/RecipeService.js +0 -786
- package/lib/service/recipe/RecipeStatsTracker.js +0 -148
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KnowledgeFileWriter — 将 KnowledgeEntry 序列化为 .md 文件 / 从 .md 解析回实体
|
|
3
|
+
*
|
|
4
|
+
* 统一替代 CandidateFileWriter + RecipeFileWriter。
|
|
5
|
+
*
|
|
6
|
+
* 职责:
|
|
7
|
+
* - KnowledgeEntry → YAML frontmatter + Markdown body (serialize)
|
|
8
|
+
* - .md 内容 → wire format JSON → KnowledgeEntry.fromJSON() (parse)
|
|
9
|
+
* - 落盘到 AutoSnippet/{candidates|recipes}/{category}/ 目录
|
|
10
|
+
* - .md 文件 = 完整唯一数据源(Source of Truth),DB = 索引缓存
|
|
11
|
+
*
|
|
12
|
+
* Frontmatter 分层:
|
|
13
|
+
* - 标量字段(人类可读/可编辑):id, title, lifecycle, language, ...
|
|
14
|
+
* - 简单数组字段(行内 JSON):tags, headers, header_paths
|
|
15
|
+
* - 值对象(_ 前缀,单行 JSON):_content, _relations, _constraints, ...
|
|
16
|
+
*
|
|
17
|
+
* 文件名策略:trigger slug > title slug > id[:8]
|
|
18
|
+
* 落盘目录:isCandidate() → candidates/ | isActive()/deprecated → recipes/
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
import { RECIPES_DIR, CANDIDATES_DIR } from '../../infrastructure/config/Defaults.js';
|
|
25
|
+
import Logger from '../../infrastructure/logging/Logger.js';
|
|
26
|
+
import pathGuard from '../../shared/PathGuard.js';
|
|
27
|
+
|
|
28
|
+
/* ═══════════════════════════════════════════════════════════
|
|
29
|
+
* 标量字段定义 — frontmatter 中直接输出为 key: value
|
|
30
|
+
* ═══════════════════════════════════════════════════════════ */
|
|
31
|
+
|
|
32
|
+
const SCALAR_FIELDS = [
|
|
33
|
+
'id', 'title', 'trigger', 'lifecycle', 'language', 'category',
|
|
34
|
+
'kind', 'knowledge_type', 'complexity', 'scope', 'difficulty',
|
|
35
|
+
'summary_cn', 'summary_en', 'usage_guide_cn', 'usage_guide_en',
|
|
36
|
+
'description', 'source', 'module_name',
|
|
37
|
+
'created_by', 'created_at', 'updated_at',
|
|
38
|
+
'published_at', 'published_by', 'reviewed_by', 'reviewed_at',
|
|
39
|
+
'rejection_reason', 'source_file', 'source_candidate_id',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/* ═══════════════════════════════════════════════════════════
|
|
43
|
+
* KnowledgeFileWriter 类
|
|
44
|
+
* ═══════════════════════════════════════════════════════════ */
|
|
45
|
+
|
|
46
|
+
export class KnowledgeFileWriter {
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} projectRoot 项目根目录
|
|
49
|
+
*/
|
|
50
|
+
constructor(projectRoot) {
|
|
51
|
+
this.projectRoot = projectRoot;
|
|
52
|
+
this.recipesDir = path.join(projectRoot, RECIPES_DIR);
|
|
53
|
+
this.candidatesDir = path.join(projectRoot, CANDIDATES_DIR);
|
|
54
|
+
this.logger = Logger.getInstance();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ═══ 序列化 ═══════════════════════════════════════════ */
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 将 KnowledgeEntry 序列化为完整 .md(YAML frontmatter + body)
|
|
61
|
+
* @param {import('../../domain/knowledge/KnowledgeEntry.js').KnowledgeEntry} entry
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
serialize(entry) {
|
|
65
|
+
const json = entry.toJSON();
|
|
66
|
+
const lines = ['---'];
|
|
67
|
+
|
|
68
|
+
// ── 标量字段(人类可读)──
|
|
69
|
+
for (const key of SCALAR_FIELDS) {
|
|
70
|
+
const val = json[key];
|
|
71
|
+
if (val != null && val !== '') {
|
|
72
|
+
lines.push(`${key}: ${_yamlValue(key, val)}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── 简单数组字段(行内 JSON)──
|
|
77
|
+
if (json.tags?.length) lines.push(`tags: ${JSON.stringify(json.tags)}`);
|
|
78
|
+
if (json.headers?.length) lines.push(`headers: ${JSON.stringify(json.headers)}`);
|
|
79
|
+
if (json.header_paths?.length) lines.push(`header_paths: ${JSON.stringify(json.header_paths)}`);
|
|
80
|
+
if (json.include_headers) lines.push(`include_headers: true`);
|
|
81
|
+
if (json.auto_approvable) lines.push(`auto_approvable: true`);
|
|
82
|
+
|
|
83
|
+
// ── JSON 值对象(_ 前缀,单行 JSON)──
|
|
84
|
+
const JSON_FIELDS = [
|
|
85
|
+
['_content', json.content],
|
|
86
|
+
['_relations', json.relations],
|
|
87
|
+
['_constraints', json.constraints],
|
|
88
|
+
['_reasoning', json.reasoning],
|
|
89
|
+
['_quality', json.quality],
|
|
90
|
+
['_stats', json.stats],
|
|
91
|
+
['_lifecycle_history', json.lifecycle_history],
|
|
92
|
+
];
|
|
93
|
+
for (const [key, val] of JSON_FIELDS) {
|
|
94
|
+
if (val && typeof val === 'object') {
|
|
95
|
+
// 跳过空对象和空数组
|
|
96
|
+
const hasContent = Array.isArray(val) ? val.length > 0 : Object.keys(val).length > 0;
|
|
97
|
+
if (hasContent) {
|
|
98
|
+
lines.push(`${key}: ${JSON.stringify(val)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (json.agent_notes) lines.push(`_agent_notes: ${JSON.stringify(json.agent_notes)}`);
|
|
103
|
+
if (json.ai_insight) lines.push(`_ai_insight: ${JSON.stringify(json.ai_insight)}`);
|
|
104
|
+
|
|
105
|
+
// _content_hash 占位(后续替换为真实 hash)
|
|
106
|
+
const hashPlaceholder = '__HASH_PLACEHOLDER__';
|
|
107
|
+
lines.push(`_content_hash: ${hashPlaceholder}`);
|
|
108
|
+
|
|
109
|
+
lines.push('---');
|
|
110
|
+
lines.push('');
|
|
111
|
+
|
|
112
|
+
// ── Body ──
|
|
113
|
+
lines.push(this._buildBody(entry));
|
|
114
|
+
lines.push('');
|
|
115
|
+
|
|
116
|
+
// ── 计算 content hash 并替换 placeholder ──
|
|
117
|
+
const md = lines.join('\n');
|
|
118
|
+
const cleanedForHash = md.replace(`_content_hash: ${hashPlaceholder}`, '');
|
|
119
|
+
const hash = computeKnowledgeHash(cleanedForHash);
|
|
120
|
+
return md.replace(hashPlaceholder, hash);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 构建 Markdown body
|
|
125
|
+
* @param {import('../../domain/knowledge/KnowledgeEntry.js').KnowledgeEntry} entry
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
_buildBody(entry) {
|
|
129
|
+
const c = entry.content;
|
|
130
|
+
const lines = [];
|
|
131
|
+
|
|
132
|
+
if (c.markdown) {
|
|
133
|
+
// Markdown 项目特写 / 完整文章 → 直接输出(去掉可能残留的 frontmatter)
|
|
134
|
+
const body = c.markdown.replace(/^---[\s\S]*?---\s*/, '').trim();
|
|
135
|
+
lines.push(body);
|
|
136
|
+
} else {
|
|
137
|
+
// 结构化构建
|
|
138
|
+
lines.push(`## ${entry.title}`);
|
|
139
|
+
lines.push('');
|
|
140
|
+
|
|
141
|
+
if (entry.summaryCn) {
|
|
142
|
+
lines.push(`> ${entry.summaryCn}`);
|
|
143
|
+
lines.push('');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (c.pattern) {
|
|
147
|
+
lines.push('```' + (entry.language || 'swift'));
|
|
148
|
+
lines.push(c.pattern);
|
|
149
|
+
lines.push('```');
|
|
150
|
+
lines.push('');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (entry.usageGuideCn) {
|
|
154
|
+
lines.push('## 使用指南');
|
|
155
|
+
lines.push('');
|
|
156
|
+
lines.push(entry.usageGuideCn);
|
|
157
|
+
lines.push('');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (c.rationale) {
|
|
161
|
+
lines.push('## 设计原理');
|
|
162
|
+
lines.push('');
|
|
163
|
+
lines.push(c.rationale);
|
|
164
|
+
lines.push('');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (c.steps?.length > 0) {
|
|
168
|
+
lines.push('## 实施步骤');
|
|
169
|
+
lines.push('');
|
|
170
|
+
for (const [i, step] of c.steps.entries()) {
|
|
171
|
+
if (typeof step === 'string') {
|
|
172
|
+
lines.push(`${i + 1}. ${step}`);
|
|
173
|
+
} else {
|
|
174
|
+
const title = step.title || '步骤';
|
|
175
|
+
const desc = step.description || '';
|
|
176
|
+
lines.push(`${i + 1}. **${title}**: ${desc}`);
|
|
177
|
+
if (step.code) {
|
|
178
|
+
lines.push('');
|
|
179
|
+
lines.push('```');
|
|
180
|
+
lines.push(step.code);
|
|
181
|
+
lines.push('```');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
lines.push('');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (entry.constraints.boundaries?.length > 0) {
|
|
189
|
+
lines.push('## 约束与边界');
|
|
190
|
+
lines.push('');
|
|
191
|
+
for (const b of entry.constraints.boundaries) {
|
|
192
|
+
lines.push(`- ${b}`);
|
|
193
|
+
}
|
|
194
|
+
lines.push('');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (entry.reasoning.whyStandard) {
|
|
198
|
+
lines.push('## Why Standard');
|
|
199
|
+
lines.push('');
|
|
200
|
+
lines.push(entry.reasoning.whyStandard);
|
|
201
|
+
lines.push('');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (entry.reasoning.sources?.length > 0) {
|
|
205
|
+
lines.push('## Sources');
|
|
206
|
+
lines.push('');
|
|
207
|
+
for (const src of entry.reasoning.sources) {
|
|
208
|
+
lines.push(`- ${src}`);
|
|
209
|
+
}
|
|
210
|
+
lines.push('');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return lines.join('\n');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* ═══ 文件操作 ═══════════════════════════════════════════ */
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 将 KnowledgeEntry 落盘到对应目录
|
|
221
|
+
* - isCandidate() → AutoSnippet/candidates/{category}/
|
|
222
|
+
* - isActive()/deprecated → AutoSnippet/recipes/{category}/
|
|
223
|
+
*
|
|
224
|
+
* @param {import('../../domain/knowledge/KnowledgeEntry.js').KnowledgeEntry} entry
|
|
225
|
+
* @returns {string|null} 写入的文件路径,失败返回 null
|
|
226
|
+
*/
|
|
227
|
+
persist(entry) {
|
|
228
|
+
try {
|
|
229
|
+
if (!entry?.id || !entry?.title) {
|
|
230
|
+
this.logger.warn('Cannot persist knowledge entry: missing id or title');
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const { dir, filename } = this._resolveFilePath(entry);
|
|
235
|
+
|
|
236
|
+
// 路径安全检查
|
|
237
|
+
pathGuard.assertProjectWriteSafe(dir);
|
|
238
|
+
|
|
239
|
+
if (!fs.existsSync(dir)) {
|
|
240
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 清理旧文件(lifecycle 切换或 category 变更场景)
|
|
244
|
+
this._cleanupOldFile(entry, path.join(dir, filename));
|
|
245
|
+
|
|
246
|
+
const filePath = path.join(dir, filename);
|
|
247
|
+
const markdown = this.serialize(entry);
|
|
248
|
+
fs.writeFileSync(filePath, markdown, 'utf8');
|
|
249
|
+
|
|
250
|
+
// 更新 entry 的 sourceFile 溯源
|
|
251
|
+
entry.sourceFile = path.relative(this.projectRoot, filePath);
|
|
252
|
+
|
|
253
|
+
this.logger.info('Knowledge entry persisted to file', {
|
|
254
|
+
entryId: entry.id,
|
|
255
|
+
lifecycle: entry.lifecycle,
|
|
256
|
+
path: entry.sourceFile,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return filePath;
|
|
260
|
+
} catch (error) {
|
|
261
|
+
this.logger.error('Failed to persist knowledge entry to file', {
|
|
262
|
+
entryId: entry?.id,
|
|
263
|
+
error: error.message,
|
|
264
|
+
});
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 删除 KnowledgeEntry 对应的 .md 文件
|
|
271
|
+
* @param {import('../../domain/knowledge/KnowledgeEntry.js').KnowledgeEntry} entry
|
|
272
|
+
* @returns {boolean}
|
|
273
|
+
*/
|
|
274
|
+
remove(entry) {
|
|
275
|
+
if (!entry?.id) return false;
|
|
276
|
+
|
|
277
|
+
// 先尝试 sourceFile 精确删除
|
|
278
|
+
if (entry.sourceFile) {
|
|
279
|
+
const fullPath = path.join(this.projectRoot, entry.sourceFile);
|
|
280
|
+
if (fs.existsSync(fullPath)) {
|
|
281
|
+
fs.unlinkSync(fullPath);
|
|
282
|
+
this.logger.info('Knowledge entry file removed', {
|
|
283
|
+
entryId: entry.id,
|
|
284
|
+
path: entry.sourceFile,
|
|
285
|
+
});
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// fallback: 按文件名在 candidates/ 和 recipes/ 中扫描
|
|
291
|
+
const { filename } = this._resolveFilePath(entry);
|
|
292
|
+
const searchDirs = [
|
|
293
|
+
path.join(this.candidatesDir, (entry.category || 'general').toLowerCase()),
|
|
294
|
+
path.join(this.recipesDir, (entry.category || 'general').toLowerCase()),
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
for (const dir of searchDirs) {
|
|
298
|
+
const fp = path.join(dir, filename);
|
|
299
|
+
if (fs.existsSync(fp)) {
|
|
300
|
+
fs.unlinkSync(fp);
|
|
301
|
+
this.logger.info('Knowledge entry file removed', { entryId: entry.id, path: fp });
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 最终 fallback: id 扫描
|
|
307
|
+
return this._removeByIdScan(entry.id);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 当 lifecycle 切换时,移动 .md 文件到正确目录
|
|
312
|
+
* candidates/ ↔ recipes/
|
|
313
|
+
*
|
|
314
|
+
* @param {import('../../domain/knowledge/KnowledgeEntry.js').KnowledgeEntry} entry
|
|
315
|
+
* @returns {string|null} 新的文件路径
|
|
316
|
+
*/
|
|
317
|
+
moveOnLifecycleChange(entry) {
|
|
318
|
+
const oldPath = entry.sourceFile
|
|
319
|
+
? path.join(this.projectRoot, entry.sourceFile)
|
|
320
|
+
: null;
|
|
321
|
+
|
|
322
|
+
const { dir: newDir, filename } = this._resolveFilePath(entry);
|
|
323
|
+
const newPath = path.join(newDir, filename);
|
|
324
|
+
|
|
325
|
+
// 如果路径没变,直接重新序列化
|
|
326
|
+
if (oldPath && path.resolve(oldPath) === path.resolve(newPath)) {
|
|
327
|
+
return this.persist(entry);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 删除旧文件
|
|
331
|
+
if (oldPath && fs.existsSync(oldPath)) {
|
|
332
|
+
fs.unlinkSync(oldPath);
|
|
333
|
+
this.logger.info('Removed old knowledge entry file on lifecycle change', {
|
|
334
|
+
entryId: entry.id,
|
|
335
|
+
oldPath: entry.sourceFile,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 写入新位置
|
|
340
|
+
return this.persist(entry);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/* ═══ 内部工具 ═══════════════════════════════════════════ */
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 计算文件存储路径
|
|
347
|
+
* @returns {{ dir: string, filename: string }}
|
|
348
|
+
*/
|
|
349
|
+
_resolveFilePath(entry) {
|
|
350
|
+
const baseDir = entry.isCandidate() ? this.candidatesDir : this.recipesDir;
|
|
351
|
+
const category = (entry.category || 'general').toLowerCase();
|
|
352
|
+
const dir = path.join(baseDir, category);
|
|
353
|
+
const filename = _slugFilename(entry.trigger, entry.title, entry.id);
|
|
354
|
+
return { dir, filename };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 清理旧文件(category 变更或 lifecycle 切换场景)
|
|
359
|
+
*/
|
|
360
|
+
_cleanupOldFile(entry, newPath) {
|
|
361
|
+
if (!entry.sourceFile) return;
|
|
362
|
+
const oldPath = path.join(this.projectRoot, entry.sourceFile);
|
|
363
|
+
if (oldPath !== newPath && fs.existsSync(oldPath)) {
|
|
364
|
+
fs.unlinkSync(oldPath);
|
|
365
|
+
this.logger.info('Cleaned up old knowledge entry file', {
|
|
366
|
+
entryId: entry.id,
|
|
367
|
+
oldPath: entry.sourceFile,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* 通过 id 扫描所有 .md 文件来删除
|
|
374
|
+
* @returns {boolean}
|
|
375
|
+
*/
|
|
376
|
+
_removeByIdScan(id) {
|
|
377
|
+
for (const baseDir of [this.candidatesDir, this.recipesDir]) {
|
|
378
|
+
if (!fs.existsSync(baseDir)) continue;
|
|
379
|
+
try {
|
|
380
|
+
const found = _walkAndRemoveById(baseDir, id);
|
|
381
|
+
if (found) {
|
|
382
|
+
this.logger.info('Knowledge entry file removed by id scan', { id });
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
} catch { /* ignore scan errors */ }
|
|
386
|
+
}
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/* ═══════════════════════════════════════════════════════════
|
|
392
|
+
* 公共工具函数
|
|
393
|
+
* ═══════════════════════════════════════════════════════════ */
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* 计算 .md 内容的 SHA-256 hash(去除 _content_hash 行后)
|
|
397
|
+
* @param {string} content
|
|
398
|
+
* @returns {string} 16 字符 hex
|
|
399
|
+
*/
|
|
400
|
+
export function computeKnowledgeHash(content) {
|
|
401
|
+
const cleaned = content.replace(/^_content_hash:.*\n?/m, '').trim();
|
|
402
|
+
return createHash('sha256').update(cleaned, 'utf8').digest('hex').slice(0, 16);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 从 .md 内容解析为 wire format JSON
|
|
407
|
+
* 返回值可直接 KnowledgeEntry.fromJSON(data) 构造实体
|
|
408
|
+
*
|
|
409
|
+
* @param {string} content .md 文件全文
|
|
410
|
+
* @param {string} [relPath] 相对路径(用于溯源)
|
|
411
|
+
* @returns {Object} wire format JSON
|
|
412
|
+
*/
|
|
413
|
+
export function parseKnowledgeMarkdown(content, relPath) {
|
|
414
|
+
const fmMatch = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
|
|
415
|
+
const data = {};
|
|
416
|
+
|
|
417
|
+
if (fmMatch) {
|
|
418
|
+
const fmLines = fmMatch[1].split('\n');
|
|
419
|
+
|
|
420
|
+
for (let i = 0; i < fmLines.length; i++) {
|
|
421
|
+
const line = fmLines[i];
|
|
422
|
+
const colonIdx = line.indexOf(':');
|
|
423
|
+
if (colonIdx <= 0) continue;
|
|
424
|
+
|
|
425
|
+
const key = line.slice(0, colonIdx).trim();
|
|
426
|
+
// 跳过带空格的非正常 key
|
|
427
|
+
if (/\s/.test(key)) continue;
|
|
428
|
+
|
|
429
|
+
let value = line.slice(colonIdx + 1).trim();
|
|
430
|
+
|
|
431
|
+
// ── _ 前缀字段:统一去掉 _ 前缀存入 data ──
|
|
432
|
+
if (key.startsWith('_')) {
|
|
433
|
+
const dataKey = key.slice(1); // _content → content, _ai_insight → ai_insight
|
|
434
|
+
|
|
435
|
+
// JSON 对象/数组值
|
|
436
|
+
if (value.startsWith('{') || value.startsWith('[')) {
|
|
437
|
+
try {
|
|
438
|
+
data[dataKey] = JSON.parse(value);
|
|
439
|
+
continue;
|
|
440
|
+
} catch {
|
|
441
|
+
// 可能是跨多行的 JSON — 尝试拼接后续行
|
|
442
|
+
let jsonStr = value;
|
|
443
|
+
while (i + 1 < fmLines.length) {
|
|
444
|
+
i++;
|
|
445
|
+
jsonStr += fmLines[i];
|
|
446
|
+
try {
|
|
447
|
+
data[dataKey] = JSON.parse(jsonStr);
|
|
448
|
+
break;
|
|
449
|
+
} catch { /* continue concatenating */ }
|
|
450
|
+
}
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// JSON 字符串值(如 _ai_insight: "text")
|
|
456
|
+
if (value.startsWith('"')) {
|
|
457
|
+
try {
|
|
458
|
+
data[dataKey] = JSON.parse(value);
|
|
459
|
+
continue;
|
|
460
|
+
} catch { /* fall through to plain string */ }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// 纯标量值(如 _content_hash: abc123)
|
|
464
|
+
if (/^\d+$/.test(value)) { data[dataKey] = parseInt(value, 10); continue; }
|
|
465
|
+
if (/^\d+\.\d+$/.test(value)) { data[dataKey] = parseFloat(value); continue; }
|
|
466
|
+
if (value === 'true') { data[dataKey] = true; continue; }
|
|
467
|
+
if (value === 'false') { data[dataKey] = false; continue; }
|
|
468
|
+
data[dataKey] = value;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── 布尔 ──
|
|
473
|
+
if (value === 'true') { data[key] = true; continue; }
|
|
474
|
+
if (value === 'false') { data[key] = false; continue; }
|
|
475
|
+
|
|
476
|
+
// ── 数值(整数或浮点) ──
|
|
477
|
+
if (/^\d+$/.test(value)) { data[key] = parseInt(value, 10); continue; }
|
|
478
|
+
if (/^\d+\.\d+$/.test(value)) { data[key] = parseFloat(value); continue; }
|
|
479
|
+
|
|
480
|
+
// ── JSON 数组(非 _ 前缀) ──
|
|
481
|
+
if (value.startsWith('[')) {
|
|
482
|
+
try { data[key] = JSON.parse(value); continue; } catch { /* fallthrough */ }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── 去引号 ──
|
|
486
|
+
if (/^".*"$/.test(value)) {
|
|
487
|
+
value = value.slice(1, -1).replace(/\\"/g, '"').replace(/\\n/g, '\n');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
data[key] = value;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ── 从 body 提取信息 ──
|
|
495
|
+
const bodyMatch = content.match(/^---[\s\S]*?---\s*\r?\n([\s\S]*)$/);
|
|
496
|
+
if (bodyMatch) {
|
|
497
|
+
const body = bodyMatch[1].trim();
|
|
498
|
+
|
|
499
|
+
// 如果 content 中没有 pattern,从 body 代码块提取
|
|
500
|
+
if (!data.content?.pattern) {
|
|
501
|
+
const codeMatch = body.match(/```\w*\n([\s\S]*?)```/);
|
|
502
|
+
if (codeMatch) {
|
|
503
|
+
data.content = data.content || {};
|
|
504
|
+
data.content.pattern = codeMatch[1].trimEnd();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// 如果 content 中没有 markdown 且 body 看起来是 Markdown 文章
|
|
509
|
+
if (!data.content?.markdown && !data.content?.pattern) {
|
|
510
|
+
const isMarkdownArticle = body.includes('— 项目特写') ||
|
|
511
|
+
(body.startsWith('#') && body.length > 200);
|
|
512
|
+
if (isMarkdownArticle) {
|
|
513
|
+
data.content = data.content || {};
|
|
514
|
+
data.content.markdown = body;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ── 元数据补充 ──
|
|
520
|
+
if (relPath) {
|
|
521
|
+
data.source_file = relPath;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ── fallback: title 从 body heading 提取 ──
|
|
525
|
+
if (!data.title) {
|
|
526
|
+
const headingMatch = content.match(/^##?\s+(.+)$/m);
|
|
527
|
+
if (headingMatch) data.title = headingMatch[1].trim();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return data;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/* ═══ 私有辅助 ═══════════════════════════════════════════ */
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* 生成文件名 slug
|
|
537
|
+
* @param {string} trigger
|
|
538
|
+
* @param {string} title
|
|
539
|
+
* @param {string} id
|
|
540
|
+
* @returns {string} 文件名(含 .md 后缀)
|
|
541
|
+
*/
|
|
542
|
+
function _slugFilename(trigger, title, id) {
|
|
543
|
+
// 优先用 trigger
|
|
544
|
+
if (trigger) {
|
|
545
|
+
const clean = trigger
|
|
546
|
+
.replace(/^@/, '')
|
|
547
|
+
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
548
|
+
.slice(0, 60);
|
|
549
|
+
if (clean.length >= 2) return `${clean}.md`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// 其次用 title
|
|
553
|
+
if (title) {
|
|
554
|
+
const slug = title
|
|
555
|
+
.toLowerCase()
|
|
556
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
|
557
|
+
.replace(/\s+/g, '-')
|
|
558
|
+
.replace(/-{2,}/g, '-')
|
|
559
|
+
.replace(/^-|-$/g, '')
|
|
560
|
+
.slice(0, 60);
|
|
561
|
+
if (slug.length >= 3) return `${slug}.md`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// 最后用 id 前 8 位
|
|
565
|
+
return `${(id || 'unknown').slice(0, 8)}.md`;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* 将 YAML 值安全序列化
|
|
570
|
+
*/
|
|
571
|
+
function _yamlValue(key, val) {
|
|
572
|
+
if (typeof val === 'number' || typeof val === 'boolean') return String(val);
|
|
573
|
+
const str = String(val);
|
|
574
|
+
// 含特殊字符时加引号
|
|
575
|
+
if (/[:#\[\]{}&*!|>'"`,@\n]/.test(str) || str.trim() !== str) {
|
|
576
|
+
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
|
|
577
|
+
}
|
|
578
|
+
return str;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* 递归扫描目录,删除包含指定 id 的 .md 文件
|
|
583
|
+
* @returns {boolean}
|
|
584
|
+
*/
|
|
585
|
+
function _walkAndRemoveById(dir, id) {
|
|
586
|
+
if (!fs.existsSync(dir)) return false;
|
|
587
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
588
|
+
const full = path.join(dir, entry.name);
|
|
589
|
+
if (entry.isDirectory()) {
|
|
590
|
+
if (_walkAndRemoveById(full, id)) return true;
|
|
591
|
+
} else if (entry.name.endsWith('.md') && !entry.name.startsWith('_')) {
|
|
592
|
+
const head = fs.readFileSync(full, 'utf8').slice(0, 500);
|
|
593
|
+
if (head.includes(`id: ${id}`)) {
|
|
594
|
+
fs.unlinkSync(full);
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export default KnowledgeFileWriter;
|