@ppdocs/mcp 2.6.24 → 2.6.26

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.
@@ -51,9 +51,11 @@ export interface NodeData {
51
51
  testRules?: string[];
52
52
  reviewRules?: string[];
53
53
  codeStyle?: string[];
54
+ unitTests?: string[];
54
55
  createdAt?: string;
55
56
  updatedAt?: string;
56
57
  lastAccessedAt?: string;
58
+ lastSyncAt?: string;
57
59
  versions?: VersionRecord[];
58
60
  bugfixes?: BugfixRecord[];
59
61
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import * as storage from '../storage/httpClient.js';
3
3
  import { decodeUnicodeEscapes, decodeObjectStrings, getRules, RULE_TYPE_LABELS } from '../utils.js';
4
+ import * as vector from '../vector/index.js';
4
5
  // 辅助函数: 包装返回结果
5
6
  function wrap(_projectId, text) {
6
7
  return { content: [{ type: 'text', text }] };
@@ -36,11 +37,27 @@ export function registerTools(server, projectId, _user) {
36
37
  dependencies: decoded.dependencies || [],
37
38
  relatedFiles: decoded.relatedFiles || []
38
39
  });
40
+ // 自动更新向量索引
41
+ try {
42
+ await vector.upsertNode(projectId, node.id, node.title, node.description, node.categories);
43
+ }
44
+ catch (e) {
45
+ console.warn('[kg_create_node] Vector index update failed:', e);
46
+ }
39
47
  return wrap(projectId, JSON.stringify(node, null, 2));
40
48
  });
41
49
  // 2. 删除节点
42
50
  server.tool('kg_delete_node', '删除节点(锁定节点和根节点不可删除)', { nodeId: z.string().describe('节点ID') }, async (args) => {
43
51
  const success = await storage.deleteNode(projectId, args.nodeId);
52
+ // 同步删除向量索引
53
+ if (success) {
54
+ try {
55
+ await vector.removeNode(projectId, args.nodeId);
56
+ }
57
+ catch (e) {
58
+ console.warn('[kg_delete_node] Vector index removal failed:', e);
59
+ }
60
+ }
44
61
  return wrap(projectId, success ? '删除成功' : '删除失败(节点不存在/已锁定/是根节点)');
45
62
  });
46
63
  // 3. 更新节点
@@ -64,17 +81,18 @@ export function registerTools(server, projectId, _user) {
64
81
  version: z.number(),
65
82
  date: z.string(),
66
83
  changes: z.string()
67
- })).optional().describe('版本记录'),
84
+ })).optional().describe('版本记录(0.1起始,AI控制递增)'),
68
85
  bugfixes: z.array(z.object({
69
86
  id: z.string(),
70
87
  date: z.string(),
71
88
  issue: z.string(),
72
89
  solution: z.string(),
73
90
  impact: z.string().optional()
74
- })).optional().describe('修复记录')
91
+ })).optional().describe('修复记录'),
92
+ lastSyncAt: z.string().optional().describe('代码↔图谱最后同步时间(ISO时间戳)')
75
93
  }, async (args) => {
76
94
  const decoded = decodeObjectStrings(args);
77
- const { nodeId, tags, relatedFiles, versions, bugfixes, path, ...rest } = decoded;
95
+ const { nodeId, tags, relatedFiles, versions, bugfixes, path, lastSyncAt, ...rest } = decoded;
78
96
  // 根节点必须使用 kg_update_root 更新
79
97
  if (nodeId === 'root') {
80
98
  return wrap(projectId, '❌ 根节点请使用 kg_update_root 方法更新');
@@ -91,7 +109,18 @@ export function registerTools(server, projectId, _user) {
91
109
  updates.bugfixes = bugfixes;
92
110
  if (path !== undefined)
93
111
  updates.path = path;
112
+ if (lastSyncAt !== undefined)
113
+ updates.lastSyncAt = lastSyncAt;
94
114
  const node = await storage.updateNode(projectId, nodeId, updates);
115
+ // 自动更新向量索引 (标题或描述变更时)
116
+ if (node && (rest.title || rest.description || tags)) {
117
+ try {
118
+ await vector.upsertNode(projectId, node.id, node.title, node.description, node.categories);
119
+ }
120
+ catch (e) {
121
+ console.warn('[kg_update_node] Vector index update failed:', e);
122
+ }
123
+ }
95
124
  return wrap(projectId, node ? JSON.stringify(node, null, 2) : '更新失败(节点不存在或已锁定)');
96
125
  });
97
126
  // 3.5 更新根节点 (项目介绍)
@@ -141,22 +170,109 @@ export function registerTools(server, projectId, _user) {
141
170
  const node = await storage.lockNode(projectId, args.nodeId, true); // 强制锁定
142
171
  return wrap(projectId, node ? JSON.stringify(node, null, 2) : '操作失败');
143
172
  });
144
- // 5. 搜索节点
145
- server.tool('kg_search', '关键词搜索节点,按命中率排序返回。空白关键词[""]返回全部节点', {
146
- keywords: z.array(z.string()).describe('关键词列表(OR逻辑),空白[""]返回全部'),
147
- limit: z.number().optional().describe('返回数量(默认10)')
173
+ // 5. 搜索节点 (混合检索: 向量语义 + 关键词)
174
+ server.tool('kg_search', '智能搜索节点(语义+关键词混合)。支持中英文语义匹配,空白关键词[""]返回全部节点', {
175
+ keywords: z.array(z.string()).describe('搜索词列表,支持语义匹配("登录"可找到auth)'),
176
+ limit: z.number().optional().describe('返回数量(默认10)'),
177
+ mode: z.enum(['hybrid', 'keyword', 'semantic']).optional().describe('搜索模式: hybrid=混合(默认), keyword=仅关键词, semantic=仅语义')
148
178
  }, async (args) => {
149
- const results = await storage.searchNodes(projectId, args.keywords, args.limit || 10);
150
- const output = results.map(r => ({
151
- id: r.node.id,
152
- title: r.node.title,
153
- tags: r.node.categories || [],
154
- status: r.node.status,
155
- summary: r.node.summary || '',
156
- hitRate: `${Math.round(r.score)}%`
179
+ const limit = args.limit || 10;
180
+ const mode = args.mode || 'hybrid';
181
+ const query = args.keywords.filter(k => k.trim()).join(' ');
182
+ // 空白关键词返回全部
183
+ if (!query) {
184
+ const results = await storage.searchNodes(projectId, args.keywords, limit);
185
+ const output = results.map(r => ({
186
+ id: r.node.id,
187
+ title: r.node.title,
188
+ tags: r.node.categories || [],
189
+ status: r.node.status,
190
+ summary: r.node.summary || '',
191
+ hitRate: `${Math.round(r.score)}%`
192
+ }));
193
+ return wrap(projectId, `${JSON.stringify(output, null, 2)}\n\n💡 需要详细内容请使用 kg_read_node(nodeId) 获取节点详情`);
194
+ }
195
+ // 获取关键词结果
196
+ let keywordResults = [];
197
+ if (mode === 'hybrid' || mode === 'keyword') {
198
+ const kResults = await storage.searchNodes(projectId, args.keywords, limit * 2);
199
+ keywordResults = kResults.map(r => ({
200
+ id: r.node.id,
201
+ title: r.node.title,
202
+ tags: r.node.categories || [],
203
+ status: r.node.status,
204
+ summary: r.node.summary || '',
205
+ score: r.score / 100 // 归一化到 0-1
206
+ }));
207
+ }
208
+ // 获取语义结果
209
+ let semanticResults = [];
210
+ if (mode === 'hybrid' || mode === 'semantic') {
211
+ try {
212
+ semanticResults = await vector.semanticSearch(projectId, query, limit * 2);
213
+ }
214
+ catch (e) {
215
+ console.warn('[kg_search] Semantic search failed, falling back to keyword:', e);
216
+ }
217
+ }
218
+ // 融合排序 (混合模式)
219
+ const scoreMap = new Map();
220
+ // 合并关键词结果
221
+ for (const r of keywordResults) {
222
+ scoreMap.set(r.id, {
223
+ id: r.id,
224
+ title: r.title,
225
+ tags: r.tags,
226
+ status: r.status,
227
+ summary: r.summary,
228
+ keywordScore: r.score,
229
+ semanticScore: 0
230
+ });
231
+ }
232
+ // 合并语义结果
233
+ for (const r of semanticResults) {
234
+ const existing = scoreMap.get(r.id);
235
+ if (existing) {
236
+ existing.semanticScore = r.similarity;
237
+ }
238
+ else {
239
+ // 需要获取节点详情
240
+ const node = await storage.getNode(projectId, r.id);
241
+ if (node) {
242
+ scoreMap.set(r.id, {
243
+ id: r.id,
244
+ title: r.title,
245
+ tags: node.categories || [],
246
+ status: node.status,
247
+ summary: node.summary || '',
248
+ keywordScore: 0,
249
+ semanticScore: r.similarity
250
+ });
251
+ }
252
+ }
253
+ }
254
+ // 计算综合得分并排序
255
+ const combined = Array.from(scoreMap.values()).map(item => {
256
+ // 混合权重: 语义 0.6 + 关键词 0.4
257
+ const finalScore = mode === 'keyword' ? item.keywordScore
258
+ : mode === 'semantic' ? item.semanticScore
259
+ : item.semanticScore * 0.6 + item.keywordScore * 0.4;
260
+ return { ...item, finalScore };
261
+ });
262
+ combined.sort((a, b) => b.finalScore - a.finalScore);
263
+ const topResults = combined.slice(0, limit);
264
+ const output = topResults.map(r => ({
265
+ id: r.id,
266
+ title: r.title,
267
+ tags: r.tags,
268
+ status: r.status,
269
+ summary: r.summary,
270
+ score: `${Math.round(r.finalScore * 100)}%`,
271
+ matchType: r.keywordScore > 0 && r.semanticScore > 0 ? 'hybrid'
272
+ : r.semanticScore > 0 ? 'semantic' : 'keyword'
157
273
  }));
158
274
  const json = JSON.stringify(output, null, 2);
159
- return wrap(projectId, `${json}\n\n💡 需要详细内容请使用 kg_read_node(nodeId) 获取节点详情`);
275
+ return wrap(projectId, `${json}\n\n💡 需要详细内容请使用 kg_read_node(nodeId) 获取节点详情\n🔍 搜索模式: ${mode}`);
160
276
  });
161
277
  // 5.5 列出所有标签
162
278
  server.tool('kg_list_tags', '获取所有节点使用过的标签列表(去重)', {}, async () => {
@@ -524,4 +640,24 @@ export function registerTools(server, projectId, _user) {
524
640
  };
525
641
  return wrap(projectId, JSON.stringify(result, null, 2));
526
642
  });
643
+ // ===================== 向量索引管理 =====================
644
+ // 16. 构建向量索引
645
+ server.tool('kg_build_index', '构建/重建向量语义索引(首次使用或索引损坏时调用)', {}, async () => {
646
+ const nodes = await storage.listNodes(projectId);
647
+ const count = await vector.buildIndex(projectId, nodes.map(n => ({
648
+ id: n.id,
649
+ title: n.title,
650
+ description: n.description,
651
+ categories: n.categories
652
+ })));
653
+ return wrap(projectId, `✅ 向量索引构建完成,共 ${count} 个节点`);
654
+ });
655
+ // 17. 查看索引状态
656
+ server.tool('kg_index_stats', '查看向量索引状态', {}, async () => {
657
+ const stats = await vector.getIndexStats(projectId);
658
+ return wrap(projectId, JSON.stringify({
659
+ ...stats,
660
+ tip: stats.count === 0 ? '索引为空,请运行 kg_build_index 构建索引' : '索引正常'
661
+ }, null, 2));
662
+ });
527
663
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 向量检索服务 - 基于 transformers.js 的语义搜索
3
+ * @version 0.1
4
+ */
5
+ interface VectorSearchResult {
6
+ id: string;
7
+ title: string;
8
+ similarity: number;
9
+ }
10
+ /**
11
+ * 添加或更新节点向量
12
+ */
13
+ export declare function upsertNode(projectId: string, nodeId: string, title: string, description: string, categories?: string[]): Promise<void>;
14
+ /**
15
+ * 删除节点向量
16
+ */
17
+ export declare function removeNode(projectId: string, nodeId: string): Promise<void>;
18
+ /**
19
+ * 向量语义搜索
20
+ */
21
+ export declare function semanticSearch(projectId: string, query: string, limit?: number): Promise<VectorSearchResult[]>;
22
+ /**
23
+ * 批量构建索引 (用于初始化或重建)
24
+ */
25
+ export declare function buildIndex(projectId: string, nodes: Array<{
26
+ id: string;
27
+ title: string;
28
+ description: string;
29
+ categories?: string[];
30
+ }>): Promise<number>;
31
+ /**
32
+ * 检查索引状态
33
+ */
34
+ export declare function getIndexStats(projectId: string): Promise<{
35
+ count: number;
36
+ model: string;
37
+ dimension: number;
38
+ loaded: boolean;
39
+ }>;
40
+ /**
41
+ * 预加载模型 (可选, 用于启动时预热)
42
+ */
43
+ export declare function preloadModel(): Promise<void>;
44
+ export {};
@@ -0,0 +1,203 @@
1
+ /**
2
+ * 向量检索服务 - 基于 transformers.js 的语义搜索
3
+ * @version 0.1
4
+ */
5
+ import { pipeline } from '@xenova/transformers';
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ // 模型配置
9
+ const MODEL_NAME = 'Xenova/all-MiniLM-L6-v2'; // 23MB, 384维, 中英双语
10
+ const VECTOR_DIM = 384;
11
+ // 单例模式
12
+ let extractor = null;
13
+ let isLoading = false;
14
+ let loadPromise = null;
15
+ // 内存索引 (projectId -> entries)
16
+ const vectorIndex = new Map();
17
+ /**
18
+ * 加载嵌入模型 (懒加载, 首次调用时下载)
19
+ */
20
+ async function getExtractor() {
21
+ if (extractor)
22
+ return extractor;
23
+ if (isLoading && loadPromise) {
24
+ return loadPromise;
25
+ }
26
+ isLoading = true;
27
+ console.log('[Vector] Loading embedding model...');
28
+ loadPromise = pipeline('feature-extraction', MODEL_NAME, {
29
+ quantized: true, // 使用量化版本,更小更快
30
+ });
31
+ extractor = await loadPromise;
32
+ isLoading = false;
33
+ console.log('[Vector] Model loaded successfully');
34
+ return extractor;
35
+ }
36
+ /**
37
+ * 文本转向量
38
+ */
39
+ async function embed(text) {
40
+ const ext = await getExtractor();
41
+ const output = await ext(text, { pooling: 'mean', normalize: true });
42
+ return Array.from(output.data);
43
+ }
44
+ /**
45
+ * 计算余弦相似度
46
+ */
47
+ function cosineSimilarity(a, b) {
48
+ if (a.length !== b.length)
49
+ return 0;
50
+ let dotProduct = 0;
51
+ let normA = 0;
52
+ let normB = 0;
53
+ for (let i = 0; i < a.length; i++) {
54
+ dotProduct += a[i] * b[i];
55
+ normA += a[i] * a[i];
56
+ normB += b[i] * b[i];
57
+ }
58
+ const magnitude = Math.sqrt(normA) * Math.sqrt(normB);
59
+ return magnitude === 0 ? 0 : dotProduct / magnitude;
60
+ }
61
+ /**
62
+ * 获取索引文件路径
63
+ */
64
+ function getIndexPath(projectId) {
65
+ const baseDir = process.env.PPDOCS_DATA_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '', '.ppdocs');
66
+ return path.join(baseDir, 'projects', projectId, 'vector-index.json');
67
+ }
68
+ /**
69
+ * 加载持久化索引
70
+ */
71
+ async function loadIndex(projectId) {
72
+ // 先检查内存
73
+ if (vectorIndex.has(projectId)) {
74
+ return vectorIndex.get(projectId);
75
+ }
76
+ // 尝试从文件加载
77
+ const indexPath = getIndexPath(projectId);
78
+ try {
79
+ if (fs.existsSync(indexPath)) {
80
+ const data = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
81
+ vectorIndex.set(projectId, data.entries || []);
82
+ console.log(`[Vector] Loaded ${data.entries?.length || 0} entries from disk`);
83
+ return data.entries || [];
84
+ }
85
+ }
86
+ catch (e) {
87
+ console.warn('[Vector] Failed to load index:', e);
88
+ }
89
+ vectorIndex.set(projectId, []);
90
+ return [];
91
+ }
92
+ /**
93
+ * 保存索引到磁盘
94
+ */
95
+ async function saveIndex(projectId) {
96
+ const entries = vectorIndex.get(projectId) || [];
97
+ const indexPath = getIndexPath(projectId);
98
+ try {
99
+ const dir = path.dirname(indexPath);
100
+ if (!fs.existsSync(dir)) {
101
+ fs.mkdirSync(dir, { recursive: true });
102
+ }
103
+ fs.writeFileSync(indexPath, JSON.stringify({
104
+ version: 1,
105
+ model: MODEL_NAME,
106
+ dimension: VECTOR_DIM,
107
+ updatedAt: new Date().toISOString(),
108
+ entries
109
+ }, null, 2));
110
+ console.log(`[Vector] Saved ${entries.length} entries to disk`);
111
+ }
112
+ catch (e) {
113
+ console.warn('[Vector] Failed to save index:', e);
114
+ }
115
+ }
116
+ /**
117
+ * 添加或更新节点向量
118
+ */
119
+ export async function upsertNode(projectId, nodeId, title, description, categories = []) {
120
+ const entries = await loadIndex(projectId);
121
+ // 合并可搜索文本
122
+ const searchableText = `${title} ${description} ${categories.join(' ')}`;
123
+ const embedding = await embed(searchableText);
124
+ // 查找现有条目
125
+ const existingIndex = entries.findIndex(e => e.id === nodeId);
126
+ const entry = { id: nodeId, title, embedding };
127
+ if (existingIndex >= 0) {
128
+ entries[existingIndex] = entry;
129
+ }
130
+ else {
131
+ entries.push(entry);
132
+ }
133
+ vectorIndex.set(projectId, entries);
134
+ await saveIndex(projectId);
135
+ }
136
+ /**
137
+ * 删除节点向量
138
+ */
139
+ export async function removeNode(projectId, nodeId) {
140
+ const entries = await loadIndex(projectId);
141
+ const filtered = entries.filter(e => e.id !== nodeId);
142
+ vectorIndex.set(projectId, filtered);
143
+ await saveIndex(projectId);
144
+ }
145
+ /**
146
+ * 向量语义搜索
147
+ */
148
+ export async function semanticSearch(projectId, query, limit = 10) {
149
+ const entries = await loadIndex(projectId);
150
+ if (entries.length === 0) {
151
+ console.log('[Vector] Index is empty, skipping semantic search');
152
+ return [];
153
+ }
154
+ // 查询向量化
155
+ const queryEmbedding = await embed(query);
156
+ // 计算相似度
157
+ const results = entries.map(entry => ({
158
+ id: entry.id,
159
+ title: entry.title,
160
+ similarity: cosineSimilarity(queryEmbedding, entry.embedding)
161
+ }));
162
+ // 排序并返回 top-k
163
+ results.sort((a, b) => b.similarity - a.similarity);
164
+ return results.slice(0, limit);
165
+ }
166
+ /**
167
+ * 批量构建索引 (用于初始化或重建)
168
+ */
169
+ export async function buildIndex(projectId, nodes) {
170
+ console.log(`[Vector] Building index for ${nodes.length} nodes...`);
171
+ const entries = [];
172
+ for (let i = 0; i < nodes.length; i++) {
173
+ const node = nodes[i];
174
+ const searchableText = `${node.title} ${node.description} ${(node.categories || []).join(' ')}`;
175
+ const embedding = await embed(searchableText);
176
+ entries.push({ id: node.id, title: node.title, embedding });
177
+ if ((i + 1) % 10 === 0) {
178
+ console.log(`[Vector] Processed ${i + 1}/${nodes.length}`);
179
+ }
180
+ }
181
+ vectorIndex.set(projectId, entries);
182
+ await saveIndex(projectId);
183
+ console.log(`[Vector] Index built with ${entries.length} entries`);
184
+ return entries.length;
185
+ }
186
+ /**
187
+ * 检查索引状态
188
+ */
189
+ export async function getIndexStats(projectId) {
190
+ const entries = await loadIndex(projectId);
191
+ return {
192
+ count: entries.length,
193
+ model: MODEL_NAME,
194
+ dimension: VECTOR_DIM,
195
+ loaded: extractor !== null
196
+ };
197
+ }
198
+ /**
199
+ * 预加载模型 (可选, 用于启动时预热)
200
+ */
201
+ export async function preloadModel() {
202
+ await getExtractor();
203
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ppdocs/mcp",
3
- "version": "2.6.24",
3
+ "version": "2.6.26",
4
4
  "description": "ppdocs MCP Server - Knowledge Graph for Claude",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -30,6 +30,7 @@
30
30
  ],
31
31
  "dependencies": {
32
32
  "@modelcontextprotocol/sdk": "^1.0.0",
33
+ "@xenova/transformers": "^2.17.2",
33
34
  "proper-lockfile": "^4.1.2",
34
35
  "zod": "^4.1.13"
35
36
  },
@@ -6,7 +6,9 @@
6
6
  | **代码即真理** | 代码与图谱冲突时以 Git 状态为准,强制覆写;禁止保留幽灵节点 |
7
7
  | **拓扑优先** | Types → Utils → Services → UI,底层未建不创建上层 |
8
8
  | **引用强约束** | relatedFiles 精确锚定物理路径;signature 为代码唯一标识符 |
9
- | **版本追踪** | 重要变更时自动追加 versions 记录 |
9
+ | **版本追踪** | 版本号以 0.1 起始,每次同步有变动必须更新版本号 (AI控制递增) |
10
+ | **版本一致性** | 同步完成后更新 lastSyncAt,用于检测代码↔图谱是否同步 |
11
+ | **标签完整性** | 每节点 ≥3 个分类标签,标签由AI管理 |
10
12
 
11
13
  ---
12
14
 
@@ -19,19 +21,32 @@
19
21
 
20
22
  ---
21
23
 
22
- ## 标准流程
24
+ ## 标准流程 (5步)
23
25
 
24
- ### Step 1: 任务初始化 + 变更检测
26
+ ### Step 1: 变更检测
25
27
  ```
26
28
  task_create({ title:"KG同步", goals:["检测变更","更新节点","验证覆盖"], related_nodes:["root"] })
27
29
 
28
30
  recent模式: git diff --name-only HEAD~3
29
31
  all模式: Glob src/**/*.{ts,tsx}
32
+
33
+ ⭐ kg_search([""]) 一次获取全部现有节点 (替代逐个search)
34
+ ```
35
+
36
+ **版本一致性检测** ⭐新增:
30
37
  ```
38
+ 对每个有 relatedFiles 的节点:
39
+ 1. 获取文件 mtime (通过 Bash stat 或 fs.statSync)
40
+ 2. 对比节点的 lastSyncAt
41
+ 3. mtime > lastSyncAt → 标记为 [OUTDATED] 需同步
42
+ 4. lastSyncAt 不存在 → 标记为 [UNTRACKED] 从未同步
43
+ ```
44
+
31
45
  **输出变更清单**:
32
46
  ```
33
47
  ├─ 🟢 [NEW] src/services/Payment.ts (待创建)
34
48
  ├─ 🟡 [MOD] src/utils/format.ts (待更新)
49
+ ├─ 🟠 [OUTDATED] src/hooks/useAuth.ts (代码已改,图谱未同步)
35
50
  └─ 🔴 [DEL] src/legacy/old.ts (待废弃)
36
51
  ```
37
52
  等待用户确认 `OK`
@@ -39,13 +54,21 @@ all模式: Glob src/**/*.{ts,tsx}
39
54
  ### Step 2: 依赖解析 + 拓扑排序
40
55
  解析 import 语句 → 构建依赖树 → 拓扑排序确保底层先处理
41
56
 
57
+ ### Step 2.5: 标签完整性预检 ⭐新增
58
+ ```
59
+ kg_list_tags() → 获取现有标签词库 (91个标签)
60
+ 检查待处理节点:
61
+ - 标签 < 3 → 自动补全建议
62
+ - 使用现有标签词库保持一致性
63
+ ```
64
+
42
65
  ### Step 3: 原子化执行
43
66
  对每个文件:
44
67
  ```
45
68
  1. 读取源码 → 提取 signature/imports/comments
46
- 2. kg_search(signature) → 检测现有节点
69
+ 2. 从 Step1 缓存查找现有节点 (无需再次kg_search)
47
70
  3. 执行写入:
48
- - NEW: kg_create_node (全字段 + relatedFiles)
71
+ - NEW: kg_create_node (全字段 + relatedFiles + ⚠️tags≥3)
49
72
  - MOD: kg_update_node (变动字段 + 追加 versions)
50
73
  - DEL: kg_update_node(status:"deprecated") 或 kg_delete_node
51
74
  4. 每5个文件: task_add_log(progress, "已处理 N/M")
@@ -58,20 +81,43 @@ all模式: Glob src/**/*.{ts,tsx}
58
81
  kg_update_node({
59
82
  nodeId: "xxx",
60
83
  description: "新描述",
84
+ lastSyncAt: new Date().toISOString(), // ⭐ 更新同步时间戳
61
85
  versions: [...现有versions, {
62
- version: 现有max+1,
86
+ version: 上一版本 + 0.1, // ⭐ 0.1起始,AI控制递增
63
87
  date: "ISO时间",
64
88
  changes: "本次变更摘要"
65
89
  }]
66
90
  })
67
91
  ```
68
92
 
69
- ### Step 4: 校验 + 交付
93
+ **代码版本注释** (源码文件头部):
94
+ ```typescript
95
+ // @version 0.1
96
+ // 或 JSDoc 格式
97
+ /**
98
+ * @version 0.1
99
+ */
70
100
  ```
71
- 1. 统计覆盖率
101
+
102
+ **同步时间戳规则** ⭐新增:
103
+ ```
104
+ NEW: kg_create_node 后自动设置 lastSyncAt = now
105
+ MOD: kg_update_node 时必须传 lastSyncAt = now
106
+ OUTDATED: 仅更新 lastSyncAt (无需改 description/versions)
107
+ ```
108
+
109
+ ### Step 4: 关系校验 ⭐优化
110
+ ```
111
+ 1. kg_read_node(nodeId, depth=1) 批量校验上下游关系
72
112
  2. kg_list_nodes({maxEdges:0}) 检查孤立节点
73
113
  3. 检查断链 (dependencies 指向不存在的 signature)
74
- 4. task_complete({
114
+ ```
115
+
116
+ ### Step 5: 交付报告 ⭐增强
117
+ ```
118
+ 1. 统计覆盖率
119
+ 2. kg_list_tags() 输出标签分布
120
+ 3. task_complete({
75
121
  summary: "同步完成...",
76
122
  difficulties: ["遇到的困难"],
77
123
  solutions: ["采用的方案"],
@@ -87,19 +133,36 @@ kg_update_node({
87
133
  | 更新节点 | 12 | ✅ |
88
134
  | 废弃节点 | 1 | ⚠️ |
89
135
  | 版本追加 | 8 | ✅ |
136
+ | 标签补全 | 5 | ✅ |
137
+ | 同步时间戳更新 | 15 | ✅ |
138
+ | 版本一致率 | 100% | 🟢 |
90
139
  | 覆盖率 | 98.5% | 🟢 |
140
+ | 标签总数 | 91 | 📊 |
91
141
 
92
142
  建议: git add . && git commit -m "chore: sync kg"
93
143
  ```
94
144
 
95
145
  ---
96
146
 
147
+ ## MCP 工具速查 ⭐新增
148
+
149
+ | 工具 | 用途 | 示例 |
150
+ |:---|:---|:---|
151
+ | `kg_search([""])` | 获取全部节点 | 变更检测时一次拉取 |
152
+ | `kg_list_tags` | 标签词库 | 保持标签一致性 |
153
+ | `kg_read_node(id, depth=1)` | 含关系的详情 | 校验上下游 |
154
+ | `kg_list_nodes({maxEdges:0})` | 孤立节点 | 发现断链 |
155
+ | `kg_list_nodes` | 含summary列表 | 覆盖率报告 |
156
+
157
+ ---
158
+
97
159
  ## 异常处理
98
160
  | 场景 | 反应 |
99
161
  |:---|:---|
100
162
  | git status 不干净 | 警告 + 询问是否强制继续 |
101
163
  | kg_search 返回多个同名节点 | 暂停,列出候选请求人工绑定 |
102
164
  | 文件解析失败 | task_add_log(issue) + 标记 Skipped,不中断队列 |
165
+ | 标签不足3个 | 自动从词库补全,记录日志 |
103
166
 
104
167
  ---
105
168
 
@@ -110,3 +173,6 @@ kg_update_node({
110
173
  | 文件路径 | relatedFiles | 相对项目根 (如 `src/utils/auth.ts`) |
111
174
  | export 函数/类 | signature | 标识符名 (如 `AuthService`) |
112
175
  | JSDoc/Comments | description | 优先注释,无则代码生成摘要 |
176
+ | 功能分类 | tags | ≥3个,使用 kg_list_tags 词库 |
177
+ | 同步时间 | lastSyncAt | 每次同步完成时设为当前 ISO 时间戳 |
178
+ | `// @version 0.1` | versions[].version | 代码文件头注释,0.1起始 |
@@ -1,41 +0,0 @@
1
- import type { NodeData, Project, SearchResult, PathResult, BugfixRecord } from './types.js';
2
- export declare function listProjects(): Promise<Project[]>;
3
- export declare function ensureProject(projectId: string): Promise<Project>;
4
- export declare function createProject(name: string): Promise<Project>;
5
- export declare function getChangeMarkerTime(projectId: string): Promise<number>;
6
- export declare function listNodes(projectId: string): Promise<NodeData[]>;
7
- export declare function getNode(projectId: string, nodeId: string): Promise<NodeData | null>;
8
- type CreateNodeInput = Omit<NodeData, 'id' | 'x' | 'y'> & {
9
- x?: number;
10
- y?: number;
11
- };
12
- export declare function createNode(projectId: string, node: CreateNodeInput): Promise<NodeData>;
13
- export declare function updateNode(projectId: string, nodeId: string, updates: Partial<NodeData>): Promise<NodeData | null>;
14
- export declare function deleteNode(projectId: string, nodeId: string): Promise<boolean>;
15
- export declare function lockNode(projectId: string, nodeId: string, locked: boolean): Promise<NodeData | null>;
16
- export declare function searchNodes(projectId: string, keywords: string[], limit?: number): Promise<SearchResult[]>;
17
- export declare function findPath(projectId: string, startId: string, endId: string): Promise<PathResult | null>;
18
- export interface OrphanNode {
19
- id: string;
20
- title: string;
21
- type: string;
22
- status: string;
23
- }
24
- export interface RelationNode {
25
- nodeId: string;
26
- title: string;
27
- description: string;
28
- edgeType: string;
29
- direction: 'outgoing' | 'incoming';
30
- }
31
- /** 查找孤立节点(没有任何边连接的节点) */
32
- export declare function findOrphans(projectId: string): Promise<OrphanNode[]>;
33
- /** 查询节点关系网(所有直接连接的节点) */
34
- export declare function getRelations(projectId: string, nodeId: string): Promise<RelationNode[]>;
35
- /** 添加错误修复记录到节点 */
36
- export declare function addBugfix(projectId: string, nodeId: string, bugfix: {
37
- issue: string;
38
- solution: string;
39
- impact?: string;
40
- }): Promise<BugfixRecord | null>;
41
- export {};
@@ -1,458 +0,0 @@
1
- import * as fs from 'fs/promises';
2
- import * as path from 'path';
3
- import { homedir } from 'os';
4
- import lockfile from 'proper-lockfile';
5
- const DATA_DIR = path.join(homedir(), '.ppdocs');
6
- // 文件锁配置
7
- const LOCK_OPTIONS = {
8
- stale: 10000, // 锁超时 10秒 (防止死锁)
9
- retries: {
10
- retries: 5, // 最多重试 5 次
11
- minTimeout: 100, // 最小等待 100ms
12
- maxTimeout: 500 // 最大等待 500ms
13
- }
14
- };
15
- async function ensureDir(dir) {
16
- await fs.mkdir(dir, { recursive: true });
17
- }
18
- async function readJSON(filePath, defaultValue) {
19
- try {
20
- const content = await fs.readFile(filePath, 'utf-8');
21
- return JSON.parse(content);
22
- }
23
- catch {
24
- return defaultValue;
25
- }
26
- }
27
- // 带文件锁的写入 (防止并发数据丢失)
28
- async function writeJSON(filePath, data) {
29
- await ensureDir(path.dirname(filePath));
30
- // 确保文件存在 (lockfile 需要文件存在)
31
- try {
32
- await fs.access(filePath);
33
- }
34
- catch {
35
- await fs.writeFile(filePath, '{}');
36
- }
37
- let release = null;
38
- try {
39
- release = await lockfile.lock(filePath, LOCK_OPTIONS);
40
- await fs.writeFile(filePath, JSON.stringify(data, null, 2));
41
- }
42
- finally {
43
- if (release)
44
- await release();
45
- }
46
- }
47
- // ============ 项目操作 ============
48
- export async function listProjects() {
49
- const filePath = path.join(DATA_DIR, 'projects.json');
50
- return readJSON(filePath, []);
51
- }
52
- export async function ensureProject(projectId) {
53
- const projects = await listProjects();
54
- const existing = projects.find(p => p.id === projectId);
55
- if (existing)
56
- return existing;
57
- // 创建新项目
58
- const project = { id: projectId, name: projectId, updatedAt: new Date().toISOString() };
59
- projects.push(project);
60
- await writeJSON(path.join(DATA_DIR, 'projects.json'), projects);
61
- // 创建根节点
62
- const rootNode = {
63
- id: 'root',
64
- title: projectId,
65
- type: 'intro',
66
- status: 'complete',
67
- x: 0, y: 0, // 根节点为坐标原点
68
- locked: true,
69
- isOrigin: true,
70
- signature: projectId,
71
- categories: ['logic'], // 与 Frontend/Rust 统一
72
- description: `# ${projectId}\n\n项目根节点`,
73
- dependencies: []
74
- };
75
- const meta = { projectId, projectName: projectId, updatedAt: project.updatedAt, edges: [] };
76
- const projectDir = path.join(DATA_DIR, 'projects', projectId);
77
- await writeJSON(path.join(projectDir, 'meta.json'), meta);
78
- await writeJSON(path.join(projectDir, 'nodes', 'root.json'), rootNode);
79
- return project;
80
- }
81
- export async function createProject(name) {
82
- const id = `project-${Date.now()}`;
83
- return ensureProject(id);
84
- }
85
- // 自动计算边 (基于 dependencies.name 匹配 signature)
86
- function computeAutoEdges(nodes) {
87
- const signatureList = [];
88
- nodes.forEach(n => {
89
- if (n.signature) {
90
- signatureList.push({ sig: n.signature.toLowerCase(), id: n.id });
91
- }
92
- });
93
- const edges = [];
94
- const edgeSet = new Set();
95
- nodes.forEach(node => {
96
- node.dependencies?.forEach(dep => {
97
- const depName = dep.name.toLowerCase();
98
- const match = signatureList.find(s => s.sig.startsWith(depName));
99
- if (match && match.id !== node.id) {
100
- const key = `${node.id}->${match.id}`;
101
- if (!edgeSet.has(key)) {
102
- edgeSet.add(key);
103
- edges.push({ source: node.id, target: match.id, type: 'uses' });
104
- }
105
- }
106
- });
107
- });
108
- return edges;
109
- }
110
- function calculateAutoPosition(nodes, dependencies) {
111
- const root = nodes.find(n => n.isOrigin);
112
- if (!root)
113
- return { x: 200, y: 200 };
114
- // 无依赖 → 随机环绕 root
115
- if (!dependencies?.length) {
116
- const angle = Math.random() * 2 * Math.PI;
117
- const radius = 200 + Math.random() * 100;
118
- return {
119
- x: Math.round(root.x + Math.cos(angle) * radius),
120
- y: Math.round(root.y + Math.sin(angle) * radius)
121
- };
122
- }
123
- // 找距 root 最远的依赖节点作为 parent
124
- let parent = null;
125
- let maxDist = -1;
126
- for (const dep of dependencies) {
127
- const depName = dep.name.toLowerCase();
128
- const match = nodes.find(n => n.signature?.toLowerCase().startsWith(depName));
129
- if (match) {
130
- const dist = Math.hypot(match.x - root.x, match.y - root.y);
131
- if (dist > maxDist) {
132
- maxDist = dist;
133
- parent = match;
134
- }
135
- }
136
- }
137
- // 无匹配依赖 → 随机环绕 root
138
- if (!parent) {
139
- const angle = Math.random() * 2 * Math.PI;
140
- const radius = 200 + Math.random() * 100;
141
- return {
142
- x: Math.round(root.x + Math.cos(angle) * radius),
143
- y: Math.round(root.y + Math.sin(angle) * radius)
144
- };
145
- }
146
- // 计算方向向量 (root → parent)
147
- let dx = parent.x - root.x;
148
- let dy = parent.y - root.y;
149
- const len = Math.hypot(dx, dy);
150
- if (len < 10) {
151
- const angle = Math.random() * 2 * Math.PI;
152
- dx = Math.cos(angle);
153
- dy = Math.sin(angle);
154
- }
155
- else {
156
- dx /= len;
157
- dy /= len;
158
- }
159
- // 新位置 = parent + 方向 * 150 (加小随机偏移防重叠)
160
- const offset = 150 + (Math.random() - 0.5) * 30;
161
- const angleOffset = (Math.random() - 0.5) * 0.3;
162
- const cos = Math.cos(angleOffset), sin = Math.sin(angleOffset);
163
- return {
164
- x: Math.round(parent.x + (dx * cos - dy * sin) * offset),
165
- y: Math.round(parent.y + (dx * sin + dy * cos) * offset)
166
- };
167
- }
168
- // ============ 节点操作 ============
169
- function getNodePath(projectId, nodeId) {
170
- return path.join(DATA_DIR, 'projects', projectId, 'nodes', `${nodeId}.json`);
171
- }
172
- function getMetaPath(projectId) {
173
- return path.join(DATA_DIR, 'projects', projectId, 'meta.json');
174
- }
175
- function getChangeMarkerPath(projectId) {
176
- return path.join(DATA_DIR, 'projects', projectId, '.changed');
177
- }
178
- // 更新变更标记 (前端用于检测 MCP 变更)
179
- async function touchChangeMarker(projectId) {
180
- const markerPath = getChangeMarkerPath(projectId);
181
- await ensureDir(path.dirname(markerPath));
182
- await fs.writeFile(markerPath, Date.now().toString());
183
- }
184
- // 获取变更标记时间戳 (前端轮询用)
185
- export async function getChangeMarkerTime(projectId) {
186
- const markerPath = getChangeMarkerPath(projectId);
187
- try {
188
- const content = await fs.readFile(markerPath, 'utf-8');
189
- return parseInt(content, 10) || 0;
190
- }
191
- catch {
192
- return 0;
193
- }
194
- }
195
- // 更新节点的最后访问时间 (触发前端发光效果)
196
- async function touchNode(projectId, nodeId) {
197
- const nodePath = getNodePath(projectId, nodeId);
198
- try {
199
- const node = await readJSON(nodePath, null);
200
- if (node) {
201
- node.lastAccessedAt = new Date().toISOString();
202
- await writeJSON(nodePath, node);
203
- }
204
- }
205
- catch { /* ignore */ }
206
- }
207
- // 批量更新访问时间 (同时更新变更标记)
208
- async function touchNodes(projectId, nodeIds) {
209
- await Promise.all(nodeIds.map(id => touchNode(projectId, id)));
210
- await touchChangeMarker(projectId);
211
- }
212
- export async function listNodes(projectId) {
213
- const nodesDir = path.join(DATA_DIR, 'projects', projectId, 'nodes');
214
- try {
215
- const files = await fs.readdir(nodesDir);
216
- const nodes = [];
217
- for (const file of files) {
218
- if (file.endsWith('.json')) {
219
- const node = await readJSON(path.join(nodesDir, file), null);
220
- if (node)
221
- nodes.push(node);
222
- }
223
- }
224
- return nodes;
225
- }
226
- catch {
227
- return [];
228
- }
229
- }
230
- export async function getNode(projectId, nodeId) {
231
- return readJSON(getNodePath(projectId, nodeId), null);
232
- }
233
- export async function createNode(projectId, node) {
234
- const id = `node-${Date.now()}`;
235
- const now = new Date().toISOString();
236
- // 自动布局: 未指定位置时计算
237
- let x = node.x;
238
- let y = node.y;
239
- if (x === undefined || y === undefined) {
240
- const nodes = await listNodes(projectId);
241
- const pos = calculateAutoPosition(nodes, node.dependencies || []);
242
- x = pos.x;
243
- y = pos.y;
244
- }
245
- const fullNode = {
246
- ...node,
247
- id,
248
- x,
249
- y,
250
- updatedAt: now,
251
- versions: [{ version: 1, date: now, changes: '初始创建' }],
252
- bugfixes: []
253
- };
254
- await writeJSON(getNodePath(projectId, id), fullNode);
255
- await touchChangeMarker(projectId);
256
- return fullNode;
257
- }
258
- export async function updateNode(projectId, nodeId, updates) {
259
- const node = await readJSON(getNodePath(projectId, nodeId), null);
260
- if (!node)
261
- return null;
262
- if (node.locked && !('locked' in updates))
263
- return null; // 锁定节点只能改locked
264
- const now = new Date().toISOString();
265
- // 自动检测变更字段
266
- const changes = [];
267
- if (updates.title !== undefined && updates.title !== node.title)
268
- changes.push('标题');
269
- if (updates.description !== undefined && updates.description !== node.description)
270
- changes.push('描述');
271
- if (updates.status !== undefined && updates.status !== node.status)
272
- changes.push('状态');
273
- if (updates.categories !== undefined && JSON.stringify(updates.categories) !== JSON.stringify(node.categories))
274
- changes.push('分类');
275
- if (updates.dependencies !== undefined && JSON.stringify(updates.dependencies) !== JSON.stringify(node.dependencies))
276
- changes.push('依赖');
277
- if (updates.signature !== undefined && updates.signature !== node.signature)
278
- changes.push('签名');
279
- // 合并更新
280
- const updated = { ...node, ...updates, updatedAt: now };
281
- // 有变更时自动追加版本记录
282
- if (changes.length > 0) {
283
- const currentVersion = node.versions?.[0]?.version || 0;
284
- const newVersion = {
285
- version: currentVersion + 1,
286
- date: now,
287
- changes: `更新: ${changes.join(', ')}`
288
- };
289
- updated.versions = [newVersion, ...(node.versions || [])].slice(0, 10);
290
- }
291
- await writeJSON(getNodePath(projectId, nodeId), updated);
292
- await touchChangeMarker(projectId);
293
- return updated;
294
- }
295
- export async function deleteNode(projectId, nodeId) {
296
- const node = await getNode(projectId, nodeId);
297
- if (!node || node.isOrigin || node.locked)
298
- return false;
299
- try {
300
- await fs.unlink(getNodePath(projectId, nodeId));
301
- await touchChangeMarker(projectId);
302
- return true;
303
- }
304
- catch {
305
- return false;
306
- }
307
- }
308
- export async function lockNode(projectId, nodeId, locked) {
309
- const node = await getNode(projectId, nodeId);
310
- if (!node || node.isOrigin)
311
- return null;
312
- const updated = { ...node, locked };
313
- await writeJSON(getNodePath(projectId, nodeId), updated);
314
- await touchChangeMarker(projectId);
315
- return updated;
316
- }
317
- // ============ 搜索 ============
318
- export async function searchNodes(projectId, keywords, limit = 20) {
319
- const nodes = await listNodes(projectId);
320
- const results = [];
321
- for (const node of nodes) {
322
- let score = 0;
323
- const matches = [];
324
- const lowerKeywords = keywords.map(k => k.toLowerCase());
325
- for (const keyword of lowerKeywords) {
326
- if (node.title?.toLowerCase().includes(keyword)) {
327
- score += 3;
328
- matches.push(`title: ${node.title}`);
329
- }
330
- if (node.signature?.toLowerCase().includes(keyword)) {
331
- score += 2;
332
- matches.push(`signature: ${node.signature}`);
333
- }
334
- if (node.categories?.some(t => t.toLowerCase().includes(keyword))) {
335
- score += 2.5;
336
- matches.push(`categories: ${node.categories.join(', ')}`);
337
- }
338
- if (node.description?.toLowerCase().includes(keyword)) {
339
- score += 1;
340
- matches.push('description');
341
- }
342
- }
343
- if (score > 0)
344
- results.push({ node, score, matches: [...new Set(matches)] });
345
- }
346
- const finalResults = results.sort((a, b) => b.score - a.score).slice(0, limit);
347
- // 触发发光效果
348
- await touchNodes(projectId, finalResults.map(r => r.node.id));
349
- return finalResults;
350
- }
351
- // ============ 路径查找 (BFS) ============
352
- export async function findPath(projectId, startId, endId) {
353
- const nodes = await listNodes(projectId);
354
- const edges = computeAutoEdges(nodes);
355
- const nodeMap = new Map(nodes.map(n => [n.id, n]));
356
- if (!nodeMap.has(startId) || !nodeMap.has(endId))
357
- return null;
358
- // 建立邻接表 (双向)
359
- const adj = new Map();
360
- for (const edge of edges) {
361
- if (!adj.has(edge.source))
362
- adj.set(edge.source, []);
363
- if (!adj.has(edge.target))
364
- adj.set(edge.target, []);
365
- adj.get(edge.source).push({ nodeId: edge.target, edge });
366
- adj.get(edge.target).push({ nodeId: edge.source, edge });
367
- }
368
- // BFS
369
- const visited = new Set([startId]);
370
- const queue = [{ nodeId: startId, path: [startId], edges: [] }];
371
- while (queue.length > 0) {
372
- const { nodeId, path, edges: pathEdges } = queue.shift();
373
- if (nodeId === endId) {
374
- // 触发发光效果
375
- await touchNodes(projectId, path);
376
- return { path: path.map(id => nodeMap.get(id)), edges: pathEdges };
377
- }
378
- for (const { nodeId: nextId, edge } of adj.get(nodeId) || []) {
379
- if (!visited.has(nextId)) {
380
- visited.add(nextId);
381
- queue.push({ nodeId: nextId, path: [...path, nextId], edges: [...pathEdges, edge] });
382
- }
383
- }
384
- }
385
- return null;
386
- }
387
- /** 查找孤立节点(没有任何边连接的节点) */
388
- export async function findOrphans(projectId) {
389
- const nodes = await listNodes(projectId);
390
- const edges = computeAutoEdges(nodes);
391
- // 收集所有有边连接的节点ID
392
- const connected = new Set();
393
- for (const edge of edges) {
394
- connected.add(edge.source);
395
- connected.add(edge.target);
396
- }
397
- // 过滤出孤立节点
398
- return nodes
399
- .filter(n => !connected.has(n.id))
400
- .map(n => ({ id: n.id, title: n.title, type: n.type, status: n.status }));
401
- }
402
- /** 查询节点关系网(所有直接连接的节点) */
403
- export async function getRelations(projectId, nodeId) {
404
- const nodes = await listNodes(projectId);
405
- const edges = computeAutoEdges(nodes);
406
- const nodeMap = new Map(nodes.map(n => [n.id, n]));
407
- const relations = [];
408
- for (const edge of edges) {
409
- let relatedId = null;
410
- let direction = null;
411
- if (edge.source === nodeId) {
412
- relatedId = edge.target;
413
- direction = 'outgoing';
414
- }
415
- else if (edge.target === nodeId) {
416
- relatedId = edge.source;
417
- direction = 'incoming';
418
- }
419
- if (relatedId && direction) {
420
- const node = nodeMap.get(relatedId);
421
- if (node) {
422
- relations.push({
423
- nodeId: relatedId,
424
- title: node.title,
425
- description: node.description.slice(0, 100),
426
- edgeType: edge.type,
427
- direction
428
- });
429
- }
430
- }
431
- }
432
- // 触发发光效果 (当前节点 + 所有关联节点)
433
- const touchList = [nodeId, ...relations.map(r => r.nodeId)];
434
- await touchNodes(projectId, touchList);
435
- return relations;
436
- }
437
- // ============ 错误修复记录 ============
438
- /** 添加错误修复记录到节点 */
439
- export async function addBugfix(projectId, nodeId, bugfix) {
440
- const node = await getNode(projectId, nodeId);
441
- if (!node || node.locked)
442
- return null;
443
- const newBugfix = {
444
- id: `bug-${Date.now()}`,
445
- date: new Date().toISOString(),
446
- issue: bugfix.issue,
447
- solution: bugfix.solution,
448
- impact: bugfix.impact
449
- };
450
- const updated = {
451
- ...node,
452
- bugfixes: [newBugfix, ...(node.bugfixes || [])].slice(0, 20), // 保留最近 20 条
453
- updatedAt: new Date().toISOString()
454
- };
455
- await writeJSON(getNodePath(projectId, nodeId), updated);
456
- await touchChangeMarker(projectId);
457
- return newBugfix;
458
- }