autosnippet 3.3.2 → 3.3.4
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 +8 -4
- package/dist/bin/cli.js +27 -1
- package/dist/lib/cli/KnowledgeSyncService.d.ts +26 -0
- package/dist/lib/cli/KnowledgeSyncService.js +33 -1
- package/dist/lib/external/mcp/handlers/browse.d.ts +1 -0
- package/dist/lib/external/mcp/handlers/browse.js +2 -1
- package/dist/lib/external/mcp/handlers/consolidated.d.ts +1 -0
- package/dist/lib/external/mcp/handlers/panorama.d.ts +11 -11
- package/dist/lib/external/mcp/handlers/panorama.js +20 -20
- package/dist/lib/external/mcp/handlers/system.d.ts +1 -1
- package/dist/lib/external/mcp/handlers/task.js +38 -15
- package/dist/lib/external/mcp/tools.d.ts +12 -12
- package/dist/lib/external/mcp/tools.js +120 -118
- package/dist/lib/http/middleware/validate.js +7 -3
- package/dist/lib/infrastructure/database/drizzle/schema.d.ts +100 -0
- package/dist/lib/infrastructure/database/drizzle/schema.js +10 -0
- package/dist/lib/infrastructure/database/migrations/005_recipe_source_refs.d.ts +9 -0
- package/dist/lib/infrastructure/database/migrations/005_recipe_source_refs.js +24 -0
- package/dist/lib/infrastructure/vector/HnswVectorAdapter.js +18 -2
- package/dist/lib/injection/ServiceContainer.js +2 -0
- package/dist/lib/injection/modules/KnowledgeModule.d.ts +5 -0
- package/dist/lib/injection/modules/KnowledgeModule.js +80 -0
- package/dist/lib/service/bootstrap/UiStartupTasks.d.ts +45 -0
- package/dist/lib/service/bootstrap/UiStartupTasks.js +101 -0
- package/dist/lib/service/evolution/ConsolidationAdvisor.js +9 -9
- package/dist/lib/service/evolution/ContradictionDetector.js +2 -2
- package/dist/lib/service/evolution/RedundancyAnalyzer.js +2 -2
- package/dist/lib/service/knowledge/SourceRefReconciler.d.ts +68 -0
- package/dist/lib/service/knowledge/SourceRefReconciler.js +309 -0
- package/dist/lib/service/panorama/PanoramaService.d.ts +18 -1
- package/dist/lib/service/panorama/PanoramaService.js +148 -5
- package/dist/lib/service/search/BM25Scorer.d.ts +2 -2
- package/dist/lib/service/search/CoarseRanker.d.ts +7 -6
- package/dist/lib/service/search/CoarseRanker.js +11 -10
- package/dist/lib/service/search/FieldWeightedScorer.d.ts +81 -0
- package/dist/lib/service/search/FieldWeightedScorer.js +318 -0
- package/dist/lib/service/search/MultiSignalRanker.d.ts +2 -2
- package/dist/lib/service/search/MultiSignalRanker.js +1 -1
- package/dist/lib/service/search/SearchEngine.d.ts +8 -7
- package/dist/lib/service/search/SearchEngine.js +59 -10
- package/dist/lib/service/search/SearchTypes.d.ts +23 -3
- package/dist/lib/service/search/SearchTypes.js +6 -1
- package/dist/lib/service/task/IntentExtractor.d.ts +11 -1
- package/dist/lib/service/task/IntentExtractor.js +137 -3
- package/dist/lib/service/task/PrimeSearchPipeline.js +95 -25
- package/dist/lib/service/vector/VectorService.d.ts +3 -0
- package/dist/lib/service/vector/VectorService.js +38 -4
- package/dist/lib/shared/schemas/mcp-tools.d.ts +1 -0
- package/dist/lib/shared/schemas/mcp-tools.js +5 -1
- package/package.json +1 -1
- package/skills/autosnippet-create/SKILL.md +98 -89
- package/skills/autosnippet-devdocs/SKILL.md +55 -60
- package/templates/guard-ci.yml +2 -2
- package/templates/instructions/conventions.md +4 -2
- package/templates/recipes-setup/_template.md +39 -39
|
@@ -172,3 +172,83 @@ export function register(c) {
|
|
|
172
172
|
return new ConsolidationAdvisor(db.getDb());
|
|
173
173
|
});
|
|
174
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* 初始化知识服务(在容器初始化后调用)
|
|
177
|
+
* 绑定 EventBus → SearchEngine.refreshIndex() + recipe_source_refs 填充
|
|
178
|
+
*/
|
|
179
|
+
export function initializeKnowledgeServices(c) {
|
|
180
|
+
if (!c.services.eventBus || !c.services.searchEngine) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const { EventBus } = await_import_EventBus();
|
|
185
|
+
const eventBus = c.get('eventBus');
|
|
186
|
+
const searchEngine = c.get('searchEngine');
|
|
187
|
+
// Bug 修复: BM25 索引与 Vector 索引一致性 — 将 knowledge:changed 事件绑定到 refreshIndex
|
|
188
|
+
eventBus.on('knowledge:changed', () => {
|
|
189
|
+
try {
|
|
190
|
+
searchEngine.refreshIndex();
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
/* refreshIndex failure is non-fatal */
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
// recipe_source_refs 填充:MCP 内提交新知识后同步更新桥接表
|
|
197
|
+
eventBus.on('knowledge:changed', (data) => {
|
|
198
|
+
try {
|
|
199
|
+
const d = data;
|
|
200
|
+
if (d.action === 'create' && d.entryId) {
|
|
201
|
+
_populateSourceRefsForEntry(c, d.entryId);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
/* sourceRef population failure is non-fatal */
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
/* EventBus/SearchEngine not available — skip binding */
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** EventBus 延迟引用(避免循环依赖) */
|
|
214
|
+
function await_import_EventBus() {
|
|
215
|
+
// EventBus 类型已经通过 container 解析,此处只用于 TS 类型
|
|
216
|
+
return {
|
|
217
|
+
EventBus: Object,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* 从 knowledge_entries.reasoning 中提取 sources 并填充 recipe_source_refs 桥接表
|
|
222
|
+
*/
|
|
223
|
+
function _populateSourceRefsForEntry(c, entryId) {
|
|
224
|
+
const db = c.get('database');
|
|
225
|
+
const rawDb = db.getDb();
|
|
226
|
+
const row = rawDb.prepare(`SELECT reasoning FROM knowledge_entries WHERE id = ?`).get(entryId);
|
|
227
|
+
if (!row?.reasoning) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
let sources = [];
|
|
231
|
+
try {
|
|
232
|
+
const reasoning = JSON.parse(row.reasoning);
|
|
233
|
+
sources = Array.isArray(reasoning.sources)
|
|
234
|
+
? reasoning.sources.filter((s) => typeof s === 'string' && s.length > 0)
|
|
235
|
+
: [];
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (sources.length === 0) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
const upsert = rawDb.prepare(`INSERT OR REPLACE INTO recipe_source_refs (recipe_id, source_path, status, verified_at)
|
|
245
|
+
VALUES (?, ?, 'active', ?)`);
|
|
246
|
+
for (const sourcePath of sources) {
|
|
247
|
+
try {
|
|
248
|
+
upsert.run(entryId, sourcePath, now);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
/* table may not exist yet */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UiStartupTasks — asd ui 启动后异步后台刷新任务
|
|
3
|
+
*
|
|
4
|
+
* 在 Dashboard 启动后异步执行,不阻塞 UI:
|
|
5
|
+
* 1. syncAll: .md → DB 全量同步 + sourceRefs 对账
|
|
6
|
+
* 2. staging promote: 到期 staging → active 晋升
|
|
7
|
+
* 3. vector reconcile: 向量对账(best-effort)
|
|
8
|
+
* 4. refreshIndex: BM25 增量刷新
|
|
9
|
+
*/
|
|
10
|
+
interface UiStartupContext {
|
|
11
|
+
projectRoot: string;
|
|
12
|
+
container: {
|
|
13
|
+
get(name: string): unknown;
|
|
14
|
+
services: Record<string, unknown>;
|
|
15
|
+
singletons: Record<string, unknown>;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export interface UiStartupReport {
|
|
19
|
+
syncAll?: {
|
|
20
|
+
synced: number;
|
|
21
|
+
created: number;
|
|
22
|
+
updated: number;
|
|
23
|
+
};
|
|
24
|
+
reconcile?: {
|
|
25
|
+
inserted: number;
|
|
26
|
+
active: number;
|
|
27
|
+
stale: number;
|
|
28
|
+
};
|
|
29
|
+
staging?: {
|
|
30
|
+
promoted: number;
|
|
31
|
+
};
|
|
32
|
+
vectorReconcile?: {
|
|
33
|
+
orphans: number;
|
|
34
|
+
missing: number;
|
|
35
|
+
};
|
|
36
|
+
indexRefresh?: boolean;
|
|
37
|
+
durationMs: number;
|
|
38
|
+
errors: string[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 异步执行所有启动后台任务。
|
|
42
|
+
* 每个阶段独立 try/catch,一个失败不影响后续。
|
|
43
|
+
*/
|
|
44
|
+
export declare function runUiStartupTasks(ctx: UiStartupContext): Promise<UiStartupReport>;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UiStartupTasks — asd ui 启动后异步后台刷新任务
|
|
3
|
+
*
|
|
4
|
+
* 在 Dashboard 启动后异步执行,不阻塞 UI:
|
|
5
|
+
* 1. syncAll: .md → DB 全量同步 + sourceRefs 对账
|
|
6
|
+
* 2. staging promote: 到期 staging → active 晋升
|
|
7
|
+
* 3. vector reconcile: 向量对账(best-effort)
|
|
8
|
+
* 4. refreshIndex: BM25 增量刷新
|
|
9
|
+
*/
|
|
10
|
+
import Logger from '../../infrastructure/logging/Logger.js';
|
|
11
|
+
const logger = Logger.getInstance();
|
|
12
|
+
/**
|
|
13
|
+
* 异步执行所有启动后台任务。
|
|
14
|
+
* 每个阶段独立 try/catch,一个失败不影响后续。
|
|
15
|
+
*/
|
|
16
|
+
export async function runUiStartupTasks(ctx) {
|
|
17
|
+
const start = Date.now();
|
|
18
|
+
const report = { durationMs: 0, errors: [] };
|
|
19
|
+
logger.info('[UiStartupTasks] Starting background refresh...');
|
|
20
|
+
// ── Stage 1: syncAll (.md → DB + sourceRefs reconcile) ──
|
|
21
|
+
try {
|
|
22
|
+
const { KnowledgeSyncService } = await import('../../cli/KnowledgeSyncService.js');
|
|
23
|
+
const syncService = new KnowledgeSyncService(ctx.projectRoot);
|
|
24
|
+
const db = ctx.container.get('database');
|
|
25
|
+
const rawDb = db.getDb();
|
|
26
|
+
const syncReport = await syncService.syncAll(rawDb, { skipViolations: true });
|
|
27
|
+
report.syncAll = {
|
|
28
|
+
synced: syncReport.synced,
|
|
29
|
+
created: syncReport.created,
|
|
30
|
+
updated: syncReport.updated,
|
|
31
|
+
};
|
|
32
|
+
if (syncReport.reconcileReport) {
|
|
33
|
+
report.reconcile = {
|
|
34
|
+
inserted: syncReport.reconcileReport.inserted,
|
|
35
|
+
active: syncReport.reconcileReport.active,
|
|
36
|
+
stale: syncReport.reconcileReport.stale,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
logger.info('[UiStartupTasks] Stage 1 complete: syncAll', report.syncAll);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
const msg = `syncAll failed: ${err.message}`;
|
|
43
|
+
report.errors.push(msg);
|
|
44
|
+
logger.warn(`[UiStartupTasks] ${msg}`);
|
|
45
|
+
}
|
|
46
|
+
// ── Stage 2: Staging auto-promotion (Bug 2 fix) ──
|
|
47
|
+
try {
|
|
48
|
+
if (ctx.container.services.stagingManager) {
|
|
49
|
+
const sm = ctx.container.get('stagingManager');
|
|
50
|
+
const result = sm.checkAndPromote();
|
|
51
|
+
report.staging = { promoted: result.promoted.length };
|
|
52
|
+
if (result.promoted.length > 0) {
|
|
53
|
+
logger.info(`[UiStartupTasks] Stage 2: auto-promoted ${result.promoted.length} staging entries`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const msg = `staging promote failed: ${err.message}`;
|
|
59
|
+
report.errors.push(msg);
|
|
60
|
+
logger.warn(`[UiStartupTasks] ${msg}`);
|
|
61
|
+
}
|
|
62
|
+
// ── Stage 3: Vector reconcile (best-effort) ──
|
|
63
|
+
try {
|
|
64
|
+
if (ctx.container.services.vectorService) {
|
|
65
|
+
const vectorService = ctx.container.get('vectorService');
|
|
66
|
+
if (vectorService.syncCoordinator &&
|
|
67
|
+
typeof vectorService.syncCoordinator.reconcile === 'function') {
|
|
68
|
+
const result = await vectorService.syncCoordinator.reconcile();
|
|
69
|
+
report.vectorReconcile = {
|
|
70
|
+
orphans: result.orphansRemoved,
|
|
71
|
+
missing: result.missingQueued,
|
|
72
|
+
};
|
|
73
|
+
logger.info('[UiStartupTasks] Stage 3: vector reconcile complete', report.vectorReconcile);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
const msg = `vector reconcile failed: ${err.message}`;
|
|
79
|
+
report.errors.push(msg);
|
|
80
|
+
logger.warn(`[UiStartupTasks] ${msg}`);
|
|
81
|
+
}
|
|
82
|
+
// ── Stage 4: BM25 index refresh ──
|
|
83
|
+
try {
|
|
84
|
+
if (ctx.container.services.searchEngine) {
|
|
85
|
+
const searchEngine = ctx.container.get('searchEngine');
|
|
86
|
+
searchEngine.refreshIndex({ force: true });
|
|
87
|
+
report.indexRefresh = true;
|
|
88
|
+
logger.info('[UiStartupTasks] Stage 4: BM25 index refreshed');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
const msg = `index refresh failed: ${err.message}`;
|
|
93
|
+
report.errors.push(msg);
|
|
94
|
+
logger.warn(`[UiStartupTasks] ${msg}`);
|
|
95
|
+
}
|
|
96
|
+
report.durationMs = Date.now() - start;
|
|
97
|
+
logger.info(`[UiStartupTasks] All tasks completed in ${report.durationMs}ms`, {
|
|
98
|
+
errors: report.errors.length,
|
|
99
|
+
});
|
|
100
|
+
return report;
|
|
101
|
+
}
|
|
@@ -301,10 +301,10 @@ export class ConsolidationAdvisor {
|
|
|
301
301
|
// 同 category 的 Recipe 是最有可能重叠的
|
|
302
302
|
rows = this.#db
|
|
303
303
|
.prepare(`SELECT id, title,
|
|
304
|
-
|
|
305
|
-
|
|
304
|
+
doClause,
|
|
305
|
+
dontClause,
|
|
306
306
|
json_extract(content, '$.coreCode') AS coreCode,
|
|
307
|
-
category, trigger,
|
|
307
|
+
category, trigger, whenClause,
|
|
308
308
|
json_extract(content, '$.pattern') AS guardPattern
|
|
309
309
|
FROM knowledge_entries
|
|
310
310
|
WHERE lifecycle IN ('active', 'staging', 'evolving', 'pending')
|
|
@@ -316,10 +316,10 @@ export class ConsolidationAdvisor {
|
|
|
316
316
|
if (rows.length < 5 && triggerPrefix.length >= 3) {
|
|
317
317
|
const extra = this.#db
|
|
318
318
|
.prepare(`SELECT id, title,
|
|
319
|
-
|
|
320
|
-
|
|
319
|
+
doClause,
|
|
320
|
+
dontClause,
|
|
321
321
|
json_extract(content, '$.coreCode') AS coreCode,
|
|
322
|
-
category, trigger,
|
|
322
|
+
category, trigger, whenClause,
|
|
323
323
|
json_extract(content, '$.pattern') AS guardPattern
|
|
324
324
|
FROM knowledge_entries
|
|
325
325
|
WHERE lifecycle IN ('active', 'staging', 'evolving', 'pending')
|
|
@@ -339,10 +339,10 @@ export class ConsolidationAdvisor {
|
|
|
339
339
|
// 无 category 时按 title 关键词粗筛
|
|
340
340
|
rows = this.#db
|
|
341
341
|
.prepare(`SELECT id, title,
|
|
342
|
-
|
|
343
|
-
|
|
342
|
+
doClause,
|
|
343
|
+
dontClause,
|
|
344
344
|
json_extract(content, '$.coreCode') AS coreCode,
|
|
345
|
-
category, trigger,
|
|
345
|
+
category, trigger, whenClause,
|
|
346
346
|
json_extract(content, '$.pattern') AS guardPattern
|
|
347
347
|
FROM knowledge_entries
|
|
348
348
|
WHERE lifecycle IN ('active', 'staging', 'evolving', 'pending')
|
|
@@ -160,8 +160,8 @@ export class ContradictionDetector {
|
|
|
160
160
|
const rows = this.#db
|
|
161
161
|
.prepare(`SELECT id, title, lifecycle, description,
|
|
162
162
|
json_extract(content, '$.markdown') AS content_markdown,
|
|
163
|
-
|
|
164
|
-
|
|
163
|
+
doClause,
|
|
164
|
+
dontClause,
|
|
165
165
|
json_extract(content, '$.pattern') AS guardPattern
|
|
166
166
|
FROM knowledge_entries
|
|
167
167
|
WHERE lifecycle IN ('active', 'staging', 'evolving')`)
|
|
@@ -92,8 +92,8 @@ export class RedundancyAnalyzer {
|
|
|
92
92
|
try {
|
|
93
93
|
const rows = this.#db
|
|
94
94
|
.prepare(`SELECT id, title,
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
doClause,
|
|
96
|
+
dontClause,
|
|
97
97
|
json_extract(content, '$.pattern') AS guardPattern,
|
|
98
98
|
json_extract(content, '$.coreCode') AS coreCode
|
|
99
99
|
FROM knowledge_entries
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SourceRefReconciler — Recipe 来源引用健康检查 + 自动修复
|
|
3
|
+
*
|
|
4
|
+
* 从 knowledge_entries.reasoning.sources 填充 recipe_source_refs 桥接表,
|
|
5
|
+
* 验证路径存在性,检测 git rename,修复路径引用。
|
|
6
|
+
*
|
|
7
|
+
* 状态机:
|
|
8
|
+
* active — 文件存在,路径有效
|
|
9
|
+
* renamed — 文件已移动到 new_path,等待修复
|
|
10
|
+
* stale — 路径失效,无法自动修复
|
|
11
|
+
*/
|
|
12
|
+
interface DatabaseLike {
|
|
13
|
+
prepare(sql: string): {
|
|
14
|
+
all(...params: unknown[]): Record<string, unknown>[];
|
|
15
|
+
get(...params: unknown[]): Record<string, unknown> | undefined;
|
|
16
|
+
run(...params: unknown[]): {
|
|
17
|
+
changes: number;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export interface ReconcileReport {
|
|
22
|
+
/** 新插入的 sourceRef 条目 */
|
|
23
|
+
inserted: number;
|
|
24
|
+
/** 验证为 active 的条目 */
|
|
25
|
+
active: number;
|
|
26
|
+
/** 标记为 stale 的条目 */
|
|
27
|
+
stale: number;
|
|
28
|
+
/** 跳过的条目(24h 内已验证) */
|
|
29
|
+
skipped: number;
|
|
30
|
+
/** 处理的 recipe 数 */
|
|
31
|
+
recipesProcessed: number;
|
|
32
|
+
}
|
|
33
|
+
export interface RepairReport {
|
|
34
|
+
/** 成功检测到 rename 的条目 */
|
|
35
|
+
renamed: number;
|
|
36
|
+
/** 仍然 stale 的条目 */
|
|
37
|
+
stillStale: number;
|
|
38
|
+
}
|
|
39
|
+
export interface ApplyReport {
|
|
40
|
+
/** 成功写回 .md 的条目 */
|
|
41
|
+
applied: number;
|
|
42
|
+
/** 写回失败的条目 */
|
|
43
|
+
failed: number;
|
|
44
|
+
}
|
|
45
|
+
export declare class SourceRefReconciler {
|
|
46
|
+
#private;
|
|
47
|
+
constructor(projectRoot: string, db: DatabaseLike, options?: {
|
|
48
|
+
ttlMs?: number;
|
|
49
|
+
});
|
|
50
|
+
/**
|
|
51
|
+
* 从 knowledge_entries.reasoning 填充 recipe_source_refs 表。
|
|
52
|
+
* 对已有条目验证路径存在性,更新 status。
|
|
53
|
+
*/
|
|
54
|
+
reconcile(opts?: {
|
|
55
|
+
force?: boolean;
|
|
56
|
+
}): ReconcileReport;
|
|
57
|
+
/**
|
|
58
|
+
* 对 stale 条目尝试 git rename 修复。
|
|
59
|
+
* 使用 execFile() 安全执行 git log(防止命令注入)。
|
|
60
|
+
*/
|
|
61
|
+
repairRenames(): Promise<RepairReport>;
|
|
62
|
+
/**
|
|
63
|
+
* 将 renamed 条目的 new_path 写回 Recipe .md 文件的 _reasoning.sources。
|
|
64
|
+
* 完成后 status → active。
|
|
65
|
+
*/
|
|
66
|
+
applyRepairs(): ApplyReport;
|
|
67
|
+
}
|
|
68
|
+
export {};
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SourceRefReconciler — Recipe 来源引用健康检查 + 自动修复
|
|
3
|
+
*
|
|
4
|
+
* 从 knowledge_entries.reasoning.sources 填充 recipe_source_refs 桥接表,
|
|
5
|
+
* 验证路径存在性,检测 git rename,修复路径引用。
|
|
6
|
+
*
|
|
7
|
+
* 状态机:
|
|
8
|
+
* active — 文件存在,路径有效
|
|
9
|
+
* renamed — 文件已移动到 new_path,等待修复
|
|
10
|
+
* stale — 路径失效,无法自动修复
|
|
11
|
+
*/
|
|
12
|
+
import { execFile } from 'node:child_process';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { promisify } from 'node:util';
|
|
16
|
+
import Logger from '../../infrastructure/logging/Logger.js';
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
/* ────────────────────── Class ────────────────────── */
|
|
19
|
+
/** 默认跳过 24h 内已验证的条目 */
|
|
20
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
export class SourceRefReconciler {
|
|
22
|
+
#projectRoot;
|
|
23
|
+
#db;
|
|
24
|
+
#logger = Logger.getInstance();
|
|
25
|
+
#ttlMs;
|
|
26
|
+
constructor(projectRoot, db, options) {
|
|
27
|
+
this.#projectRoot = projectRoot;
|
|
28
|
+
this.#db = db;
|
|
29
|
+
this.#ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 从 knowledge_entries.reasoning 填充 recipe_source_refs 表。
|
|
33
|
+
* 对已有条目验证路径存在性,更新 status。
|
|
34
|
+
*/
|
|
35
|
+
reconcile(opts) {
|
|
36
|
+
const force = opts?.force ?? false;
|
|
37
|
+
const report = {
|
|
38
|
+
inserted: 0,
|
|
39
|
+
active: 0,
|
|
40
|
+
stale: 0,
|
|
41
|
+
skipped: 0,
|
|
42
|
+
recipesProcessed: 0,
|
|
43
|
+
};
|
|
44
|
+
// 确保表存在(兼容未跑 migration 的场景)
|
|
45
|
+
this.#ensureTable();
|
|
46
|
+
// 获取所有有 reasoning 的知识条目
|
|
47
|
+
const rows = this.#db
|
|
48
|
+
.prepare(`SELECT id, reasoning FROM knowledge_entries WHERE reasoning IS NOT NULL AND reasoning != '{}'`)
|
|
49
|
+
.all();
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
let sources = [];
|
|
53
|
+
try {
|
|
54
|
+
const reasoning = JSON.parse(row.reasoning);
|
|
55
|
+
sources = Array.isArray(reasoning.sources)
|
|
56
|
+
? reasoning.sources.filter((s) => typeof s === 'string' && s.length > 0)
|
|
57
|
+
: [];
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (sources.length === 0) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
report.recipesProcessed++;
|
|
66
|
+
for (const sourcePath of sources) {
|
|
67
|
+
// 检查是否已有记录
|
|
68
|
+
const existing = this.#db
|
|
69
|
+
.prepare(`SELECT status, verified_at FROM recipe_source_refs WHERE recipe_id = ? AND source_path = ?`)
|
|
70
|
+
.get(row.id, sourcePath);
|
|
71
|
+
if (existing && !force) {
|
|
72
|
+
// TTL 检查:跳过近期已验证的条目
|
|
73
|
+
if (now - existing.verified_at < this.#ttlMs) {
|
|
74
|
+
report.skipped++;
|
|
75
|
+
if (existing.status === 'active') {
|
|
76
|
+
report.active++;
|
|
77
|
+
}
|
|
78
|
+
else if (existing.status === 'stale') {
|
|
79
|
+
report.stale++;
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// 验证路径存在性
|
|
85
|
+
const absPath = path.resolve(this.#projectRoot, sourcePath);
|
|
86
|
+
const exists = fs.existsSync(absPath);
|
|
87
|
+
if (existing) {
|
|
88
|
+
// 更新已有记录
|
|
89
|
+
if (exists) {
|
|
90
|
+
this.#db
|
|
91
|
+
.prepare(`UPDATE recipe_source_refs SET status = 'active', new_path = NULL, verified_at = ? WHERE recipe_id = ? AND source_path = ?`)
|
|
92
|
+
.run(now, row.id, sourcePath);
|
|
93
|
+
report.active++;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
this.#db
|
|
97
|
+
.prepare(`UPDATE recipe_source_refs SET status = 'stale', verified_at = ? WHERE recipe_id = ? AND source_path = ?`)
|
|
98
|
+
.run(now, row.id, sourcePath);
|
|
99
|
+
report.stale++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// 新增记录
|
|
104
|
+
const status = exists ? 'active' : 'stale';
|
|
105
|
+
this.#db
|
|
106
|
+
.prepare(`INSERT OR REPLACE INTO recipe_source_refs (recipe_id, source_path, status, verified_at) VALUES (?, ?, ?, ?)`)
|
|
107
|
+
.run(row.id, sourcePath, status, now);
|
|
108
|
+
report.inserted++;
|
|
109
|
+
if (exists) {
|
|
110
|
+
report.active++;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
report.stale++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
this.#logger.info('SourceRefReconciler: reconcile complete', {
|
|
119
|
+
inserted: report.inserted,
|
|
120
|
+
active: report.active,
|
|
121
|
+
stale: report.stale,
|
|
122
|
+
skipped: report.skipped,
|
|
123
|
+
recipesProcessed: report.recipesProcessed,
|
|
124
|
+
});
|
|
125
|
+
return report;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 对 stale 条目尝试 git rename 修复。
|
|
129
|
+
* 使用 execFile() 安全执行 git log(防止命令注入)。
|
|
130
|
+
*/
|
|
131
|
+
async repairRenames() {
|
|
132
|
+
const report = { renamed: 0, stillStale: 0 };
|
|
133
|
+
// 获取所有 stale 条目
|
|
134
|
+
const staleRows = this.#db
|
|
135
|
+
.prepare(`SELECT recipe_id, source_path FROM recipe_source_refs WHERE status = 'stale'`)
|
|
136
|
+
.all();
|
|
137
|
+
if (staleRows.length === 0) {
|
|
138
|
+
return report;
|
|
139
|
+
}
|
|
140
|
+
// 获取 git rename 映射
|
|
141
|
+
const renameMap = await this.#getGitRenameMap();
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
for (const row of staleRows) {
|
|
144
|
+
const newPath = renameMap.get(row.source_path);
|
|
145
|
+
if (newPath) {
|
|
146
|
+
// 验证 newPath 存在
|
|
147
|
+
const absNewPath = path.resolve(this.#projectRoot, newPath);
|
|
148
|
+
if (fs.existsSync(absNewPath)) {
|
|
149
|
+
this.#db
|
|
150
|
+
.prepare(`UPDATE recipe_source_refs SET status = 'renamed', new_path = ?, verified_at = ? WHERE recipe_id = ? AND source_path = ?`)
|
|
151
|
+
.run(newPath, now, row.recipe_id, row.source_path);
|
|
152
|
+
report.renamed++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
report.stillStale++;
|
|
157
|
+
}
|
|
158
|
+
if (report.renamed > 0) {
|
|
159
|
+
this.#logger.info('SourceRefReconciler: rename repair complete', {
|
|
160
|
+
renamed: report.renamed,
|
|
161
|
+
stillStale: report.stillStale,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return report;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* 将 renamed 条目的 new_path 写回 Recipe .md 文件的 _reasoning.sources。
|
|
168
|
+
* 完成后 status → active。
|
|
169
|
+
*/
|
|
170
|
+
applyRepairs() {
|
|
171
|
+
const report = { applied: 0, failed: 0 };
|
|
172
|
+
const renamedRows = this.#db
|
|
173
|
+
.prepare(`SELECT recipe_id, source_path, new_path FROM recipe_source_refs WHERE status = 'renamed' AND new_path IS NOT NULL`)
|
|
174
|
+
.all();
|
|
175
|
+
if (renamedRows.length === 0) {
|
|
176
|
+
return report;
|
|
177
|
+
}
|
|
178
|
+
// 按 recipe_id 分组
|
|
179
|
+
const byRecipe = new Map();
|
|
180
|
+
for (const row of renamedRows) {
|
|
181
|
+
if (!byRecipe.has(row.recipe_id)) {
|
|
182
|
+
byRecipe.set(row.recipe_id, []);
|
|
183
|
+
}
|
|
184
|
+
byRecipe.get(row.recipe_id)?.push({ source_path: row.source_path, new_path: row.new_path });
|
|
185
|
+
}
|
|
186
|
+
// 获取 recipe 的 sourceFile 以定位 .md 文件
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
for (const [recipeId, renames] of byRecipe) {
|
|
189
|
+
try {
|
|
190
|
+
const entry = this.#db
|
|
191
|
+
.prepare(`SELECT sourceFile, reasoning FROM knowledge_entries WHERE id = ?`)
|
|
192
|
+
.get(recipeId);
|
|
193
|
+
if (!entry?.sourceFile || !entry.reasoning) {
|
|
194
|
+
report.failed += renames.length;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const mdPath = path.resolve(this.#projectRoot, entry.sourceFile);
|
|
198
|
+
if (!fs.existsSync(mdPath)) {
|
|
199
|
+
report.failed += renames.length;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
// 读取并修改 .md 文件中的 reasoning.sources
|
|
203
|
+
const _content = fs.readFileSync(mdPath, 'utf8');
|
|
204
|
+
let reasoning;
|
|
205
|
+
try {
|
|
206
|
+
reasoning = JSON.parse(entry.reasoning);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
report.failed += renames.length;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const sources = Array.isArray(reasoning.sources) ? [...reasoning.sources] : [];
|
|
213
|
+
let modified = false;
|
|
214
|
+
for (const rename of renames) {
|
|
215
|
+
const idx = sources.indexOf(rename.source_path);
|
|
216
|
+
if (idx >= 0) {
|
|
217
|
+
sources[idx] = rename.new_path;
|
|
218
|
+
modified = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (modified) {
|
|
222
|
+
reasoning.sources = sources;
|
|
223
|
+
// 更新 .md 文件中的 reasoning frontmatter
|
|
224
|
+
// 查找 YAML frontmatter 中的 reasoning 并替换
|
|
225
|
+
const updatedReasoning = JSON.stringify(reasoning);
|
|
226
|
+
// 更新 DB reasoning 列
|
|
227
|
+
this.#db
|
|
228
|
+
.prepare(`UPDATE knowledge_entries SET reasoning = ?, updatedAt = ? WHERE id = ?`)
|
|
229
|
+
.run(updatedReasoning, now, recipeId);
|
|
230
|
+
// 更新 recipe_source_refs 状态
|
|
231
|
+
for (const rename of renames) {
|
|
232
|
+
this.#db
|
|
233
|
+
.prepare(`UPDATE recipe_source_refs SET status = 'active', source_path = ?, new_path = NULL, verified_at = ? WHERE recipe_id = ? AND source_path = ?`)
|
|
234
|
+
.run(rename.new_path, now, recipeId, rename.source_path);
|
|
235
|
+
}
|
|
236
|
+
report.applied += renames.length;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
report.failed += renames.length;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
this.#logger.warn('SourceRefReconciler: applyRepairs failed for recipe', {
|
|
244
|
+
recipeId,
|
|
245
|
+
error: err.message,
|
|
246
|
+
});
|
|
247
|
+
report.failed += renames.length;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (report.applied > 0) {
|
|
251
|
+
this.#logger.info('SourceRefReconciler: applyRepairs complete', report);
|
|
252
|
+
}
|
|
253
|
+
return report;
|
|
254
|
+
}
|
|
255
|
+
/* ═══ Private helpers ═══════════════════════════════ */
|
|
256
|
+
#ensureTable() {
|
|
257
|
+
try {
|
|
258
|
+
this.#db.prepare(`SELECT 1 FROM recipe_source_refs LIMIT 1`).get();
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// 表不存在,创建之
|
|
262
|
+
this.#db.exec?.(`CREATE TABLE IF NOT EXISTS recipe_source_refs (
|
|
263
|
+
recipe_id TEXT NOT NULL,
|
|
264
|
+
source_path TEXT NOT NULL,
|
|
265
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
266
|
+
new_path TEXT,
|
|
267
|
+
verified_at INTEGER NOT NULL,
|
|
268
|
+
PRIMARY KEY (recipe_id, source_path),
|
|
269
|
+
FOREIGN KEY (recipe_id) REFERENCES knowledge_entries(id) ON DELETE CASCADE
|
|
270
|
+
);
|
|
271
|
+
CREATE INDEX IF NOT EXISTS idx_rsr_path ON recipe_source_refs(source_path);
|
|
272
|
+
CREATE INDEX IF NOT EXISTS idx_rsr_status ON recipe_source_refs(status);`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* 通过 git log 获取 rename 映射(旧路径 → 新路径)
|
|
277
|
+
* 使用 execFile 防止命令注入
|
|
278
|
+
*/
|
|
279
|
+
async #getGitRenameMap() {
|
|
280
|
+
const renameMap = new Map();
|
|
281
|
+
try {
|
|
282
|
+
const { stdout } = await execFileAsync('git', ['log', '--diff-filter=R', '--name-status', '--pretty=format:', '-n', '200'], {
|
|
283
|
+
cwd: this.#projectRoot,
|
|
284
|
+
timeout: 10000,
|
|
285
|
+
maxBuffer: 1024 * 1024,
|
|
286
|
+
});
|
|
287
|
+
// 解析 git log 输出: R100\told_path\tnew_path
|
|
288
|
+
for (const line of stdout.split('\n')) {
|
|
289
|
+
const trimmed = line.trim();
|
|
290
|
+
if (!trimmed.startsWith('R')) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const parts = trimmed.split('\t');
|
|
294
|
+
if (parts.length >= 3) {
|
|
295
|
+
const oldPath = parts[1];
|
|
296
|
+
const newPath = parts[2];
|
|
297
|
+
if (oldPath && newPath) {
|
|
298
|
+
renameMap.set(oldPath, newPath);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// git 不可用或不在 git 仓库中 — 跳过 rename 检测
|
|
305
|
+
this.#logger.debug('SourceRefReconciler: git rename detection unavailable');
|
|
306
|
+
}
|
|
307
|
+
return renameMap;
|
|
308
|
+
}
|
|
309
|
+
}
|