diff --git a/README.md b/README.md index 05e98e4..4cdd22b 100755 --- a/README.md +++ b/README.md @@ -165,9 +165,10 @@ include: - '**/*.md' - '**/*.yaml' exclude: - - 'node_modules/**' - - '.git/**' - - 'dist/**' + - '**/node_modules/**' + - '**/.git/**' + - '**/dist/**' + - '**/release/*-unpacked/**' categories: - architecture - operations diff --git a/README.zh-CN.md b/README.zh-CN.md index d7bc616..0e2022d 100755 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -165,9 +165,10 @@ include: - '**/*.md' - '**/*.yaml' exclude: - - 'node_modules/**' - - '.git/**' - - 'dist/**' + - '**/node_modules/**' + - '**/.git/**' + - '**/dist/**' + - '**/release/*-unpacked/**' categories: - architecture - operations diff --git a/package-lock.json b/package-lock.json index f7f3231..854d5e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -273,6 +273,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -321,6 +322,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1901,6 +1903,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2009,8 +2012,7 @@ "version": "2.0.46", "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/hast": { "version": "3.0.4", @@ -2078,6 +2080,7 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2088,6 +2091,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2134,6 +2138,7 @@ "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", @@ -2527,6 +2532,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3650,6 +3656,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4553,6 +4560,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -4910,6 +4918,7 @@ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", @@ -6158,6 +6167,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6509,6 +6519,7 @@ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6519,6 +6530,7 @@ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7533,6 +7545,7 @@ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -7633,6 +7646,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7833,6 +7847,7 @@ "resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.4.tgz", "integrity": "sha512-TsN0sMHqIRpdfg6TNPtfdINpkgxtnQP6JNWCaiSwvou5seXqKiP5eERkaBg+Y56wyJ4FZTeOEs/dEmWEPrpltQ==", "license": "(Apache-2.0 OR MIT)", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/visjs" @@ -7884,6 +7899,7 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -8721,6 +8737,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -8777,6 +8794,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/scripts/check-test-path-hygiene.mjs b/scripts/check-test-path-hygiene.mjs new file mode 100644 index 0000000..9289c15 --- /dev/null +++ b/scripts/check-test-path-hygiene.mjs @@ -0,0 +1,122 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const testsRoot = join(workspaceRoot, 'tests'); +const textExtensions = new Set([ + '.js', + '.json', + '.jsx', + '.md', + '.mjs', + '.snap', + '.ts', + '.tsx', + '.txt', + '.yaml', + '.yml', +]); +const machinePaths = unique([workspaceRoot, homedir(), readGitDir(workspaceRoot)]) + .flatMap(pathVariants) + .map(normalize); +const userDirectoryPatterns = [ + /[A-Za-z]:[\\/]+Users[\\/]+[^\\/\s"'`]+/gi, + /\/(?:home|Users)\/[^/\s"'`]+/g, +]; +const violations = []; + +for (const file of listTextFiles(testsRoot)) { + const content = readFileSync(file, 'utf8'); + const searchable = normalize(content); + const displayPath = relative(workspaceRoot, file).replaceAll('\\', '/'); + for (const machinePath of machinePaths) { + const index = searchable.indexOf(machinePath); + if (index >= 0) { + violations.push({ + file: displayPath, + line: lineNumberAt(searchable, index), + reason: '包含当前机器的工作区、用户目录或 Git 元数据路径', + }); + } + } + for (const pattern of userDirectoryPatterns) { + pattern.lastIndex = 0; + for (const match of content.matchAll(pattern)) { + violations.push({ + file: displayPath, + line: lineNumberAt(content, match.index ?? 0), + reason: '包含平台用户目录形式的绝对路径', + }); + } + } +} + +if (violations.length > 0) { + console.error('测试路径隔离检查失败:'); + for (const violation of deduplicate(violations)) { + console.error(`- ${violation.file}:${violation.line} ${violation.reason}`); + } + console.error('请使用动态临时目录,或用 virtual-* 相对路径表达纯路径语义。'); + process.exitCode = 1; +} + +function listTextFiles(root) { + const files = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === 'vendor') continue; + const path = join(root, entry.name); + if (entry.isDirectory()) files.push(...listTextFiles(path)); + else if (entry.isFile() && textExtensions.has(extname(entry.name).toLowerCase())) + files.push(path); + } + return files; +} + +function readGitDir(root) { + const dotGit = join(root, '.git'); + try { + if (!statSync(dotGit).isFile()) return ''; + const value = /^gitdir:\s*(.+)$/im.exec(readFileSync(dotGit, 'utf8'))?.[1]?.trim(); + if (!value) return ''; + return isAbsolute(value) ? value : resolve(root, value); + } catch { + return ''; + } +} + +function pathVariants(path) { + const slashPath = path.replaceAll('\\', '/'); + const variants = new Set([ + path, + slashPath, + path.replaceAll('\\', '\\\\'), + slashPath.replaceAll('/', '\\\\'), + ]); + const drive = /^([A-Za-z]):\/(.*)$/.exec(slashPath); + if (drive) variants.add(`/mnt/${drive[1].toLowerCase()}/${drive[2]}`); + return [...variants]; +} + +function unique(values) { + return [...new Set(values.map(value => value.trim()).filter(Boolean))]; +} + +function normalize(value) { + return process.platform === 'win32' ? value.toLowerCase() : value; +} + +function lineNumberAt(content, index) { + return content.slice(0, index).split(/\r?\n/).length; +} + +function deduplicate(items) { + const seen = new Set(); + return items.filter(item => { + const key = `${item.file}:${item.line}:${item.reason}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/scripts/lint.mjs b/scripts/lint.mjs index 022a8d7..e3a868a 100644 --- a/scripts/lint.mjs +++ b/scripts/lint.mjs @@ -2,17 +2,26 @@ import { spawnSync } from 'node:child_process'; import { resolve } from 'node:path'; const eslintCli = resolve('node_modules/eslint/bin/eslint.js'); -const result = spawnSync(process.execPath, [eslintCli, 'src', '--ext', 'ts'], { - env: { - ...process.env, - ESLINT_USE_FLAT_CONFIG: 'false', +const checks = [ + { + args: [eslintCli, 'src', '--ext', 'ts'], + env: { ...process.env, ESLINT_USE_FLAT_CONFIG: 'false' }, }, - stdio: 'inherit', -}); + { args: [resolve('scripts/check-test-path-hygiene.mjs')], env: process.env }, +]; -if (result.error) { - console.error(`Failed to start ESLint: ${result.error.message}`); - process.exitCode = 1; -} else { - process.exitCode = result.status ?? 1; +for (const check of checks) { + const result = spawnSync(process.execPath, check.args, { + env: check.env, + stdio: 'inherit', + }); + if (result.error) { + console.error(`Failed to start lint check: ${result.error.message}`); + process.exitCode = 1; + break; + } + if (result.status !== 0) { + process.exitCode = result.status ?? 1; + break; + } } diff --git a/src/advance/archive/archive-path.ts b/src/advance/archive/archive-path.ts index fd7fb53..f04da42 100755 --- a/src/advance/archive/archive-path.ts +++ b/src/advance/archive/archive-path.ts @@ -38,7 +38,12 @@ export function computeArchivePath(params: ComputeArchivePathParams): string { export function computeFlaggedArchivePath(params: ComputeArchivePathParams): string { const base = computeArchivePath(params); - return join(dirname(base), 'flagged', basename(base)); + return toFlaggedArchivePath(base); +} + +/** 将已计算的标准归档路径转换为同层 flagged 子目录路径。 */ +export function toFlaggedArchivePath(archivePath: string): string { + return join(dirname(archivePath), 'flagged', basename(archivePath)); } export function sanitizePathSegment(segment: string): string { diff --git a/src/advance/archive/classifier.ts b/src/advance/archive/classifier.ts index 2bc7062..76b8234 100755 --- a/src/advance/archive/classifier.ts +++ b/src/advance/archive/classifier.ts @@ -1,7 +1,15 @@ -import type { LlmClient } from '../llm/client'; -import { buildClassifyPrompt, parseClassifyResponse, DEFAULT_CATEGORIES, DEFAULT_DOC_TYPES } from '../llm/prompts/classify-prompt'; +import { basename } from 'node:path'; +import { LlmStructuredOutputError, type LlmClient } from '../llm/client'; +import type { ToolDefinition } from '../llm/tool-types'; +import { + buildClassifyPrompt, + parseClassifyResponse, + DEFAULT_CATEGORIES, + DEFAULT_DOC_TYPES, +} from '../llm/prompts/classify-prompt'; import { extractMetadata, type FileMetadata } from './metadata-extractor'; import type { ClassificationResult } from '../types'; +import { logger } from '../../core/logger'; export interface ClassifierOptions { /** 自定义分类列表 */ @@ -29,7 +37,11 @@ export class DocumentClassifier { const metadata = extractMetadata(filePath); // 启发置信度足够高时,直接返回启发结果 - if (metadata.estimatedCategory && metadata.estimatedDocType && metadata.heuristicConfidence >= threshold) { + if ( + metadata.estimatedCategory && + metadata.estimatedDocType && + metadata.heuristicConfidence >= threshold + ) { return { category: metadata.estimatedCategory, docType: metadata.estimatedDocType, @@ -42,15 +54,46 @@ export class DocumentClassifier { // 否则调用 LLM,优先只给 metadata,必要时给 content preview const contentPreview = content.slice(0, 1500); - const prompt = buildClassifyPrompt(filePath, metadata, contentPreview, { categories, docTypes }); - const text = await this.client.complete(prompt, '你是一名严谨的知识库管理员,只输出 JSON。'); + const promptMetadata = { + ...metadata, + sourcePath: metadata.fileName, + pathTokens: tokenizeFileName(metadata.fileName), + }; + const prompt = buildClassifyPrompt(metadata.fileName, promptMetadata, contentPreview, { + categories, + docTypes, + }); + const acceptedCategories = categories.length > 0 ? categories : DEFAULT_CATEGORIES; + const acceptedDocTypes = docTypes.length > 0 ? docTypes : DEFAULT_DOC_TYPES; + let text: string; + try { + text = await this.client.completeJson( + prompt, + '你是一名严谨的知识库管理员,只通过结构化 JSON 返回分类结果。', + buildClassificationSchema(acceptedCategories, acceptedDocTypes) + ); + } catch (error) { + if (!(error instanceof LlmStructuredOutputError)) { + throw error; + } + logger.warn( + { + fileName: basename(filePath), + errorType: error instanceof Error ? error.name : typeof error, + }, + 'LLM 分类请求失败' + ); + return fallbackClassification(); + } const parsed = parseClassifyResponse(text); if (parsed) { - const acceptedCategories = categories.length > 0 ? categories : DEFAULT_CATEGORIES; - const acceptedDocTypes = docTypes.length > 0 ? docTypes : DEFAULT_DOC_TYPES; - const normalizedCategory = acceptedCategories.includes(parsed.category) ? parsed.category : 'other'; - const normalizedDocType = acceptedDocTypes.includes(parsed.docType) ? parsed.docType : 'other'; + const normalizedCategory = acceptedCategories.includes(parsed.category) + ? parsed.category + : 'other'; + const normalizedDocType = acceptedDocTypes.includes(parsed.docType) + ? parsed.docType + : 'other'; return { category: normalizedCategory, docType: normalizedDocType, @@ -61,14 +104,8 @@ export class DocumentClassifier { }; } - return { - category: 'other', - docType: 'other', - tags: [], - summary: '自动分类失败,等待人工 review', - sections: [], - confidence: 0, - }; + logger.warn({ fileName: basename(filePath), responseLength: text.length }, 'LLM 分类解析失败'); + return fallbackClassification(); } /** @@ -78,3 +115,58 @@ export class DocumentClassifier { return extractMetadata(filePath); } } + +function buildClassificationSchema( + categories: string[], + docTypes: string[] +): ToolDefinition['input_schema'] { + return { + type: 'object', + properties: { + category: { type: 'string', enum: [...new Set([...categories, 'other'])] }, + docType: { type: 'string', enum: [...new Set([...docTypes, 'other'])] }, + tags: { + type: 'array', + items: { type: 'string' }, + minItems: 0, + maxItems: 5, + }, + summary: { type: 'string', maxLength: 100 }, + sections: { + type: 'array', + items: { + type: 'object', + properties: { + heading: { type: 'string' }, + summary: { type: 'string' }, + confidence: { type: 'number', minimum: 0, maximum: 1 }, + }, + required: ['heading', 'summary', 'confidence'], + additionalProperties: false, + }, + maxItems: 5, + }, + confidence: { type: 'number', minimum: 0, maximum: 1 }, + }, + required: ['category', 'docType', 'tags', 'summary', 'sections', 'confidence'], + additionalProperties: false, + }; +} + +function fallbackClassification(): ClassificationResult { + return { + category: 'other', + docType: 'other', + tags: [], + summary: '自动分类失败,等待人工 review', + sections: [], + confidence: 0, + }; +} + +function tokenizeFileName(fileName: string): string[] { + return fileName + .split(/[_\-.]+/) + .map(part => part.toLowerCase()) + .filter(part => part.length > 1 && !/^\d+$/.test(part)); +} diff --git a/src/advance/archive/suggestion-engine.ts b/src/advance/archive/suggestion-engine.ts index 97d2af3..763940e 100755 --- a/src/advance/archive/suggestion-engine.ts +++ b/src/advance/archive/suggestion-engine.ts @@ -1,8 +1,24 @@ import { createHash } from 'node:crypto'; -import type { LlmClient } from '../llm/client'; +import { basename } from 'node:path'; +import { LlmStructuredOutputError, type LlmClient } from '../llm/client'; +import type { ToolDefinition } from '../llm/tool-types'; import { buildSuggestPrompt, parseSuggestResponse } from '../llm/prompts/suggest-prompt'; import type { ArchiveAction, ClassificationResult } from '../types'; import { logger } from '../../core/logger'; +import { toFlaggedArchivePath } from './archive-path'; + +const SUGGEST_RESPONSE_SCHEMA: ToolDefinition['input_schema'] = { + type: 'object', + properties: { + type: { type: 'string', enum: ['copy', 'ignore', 'flag'] }, + rationale: { type: 'string' }, + risk: { type: 'string', enum: ['low', 'medium', 'high'] }, + confidence: { type: 'number', minimum: 0, maximum: 1 }, + needsReview: { type: 'boolean' }, + }, + required: ['type', 'rationale', 'risk', 'confidence', 'needsReview'], + additionalProperties: false, +}; export interface SuggestContext { dedupRelation?: 'duplicate' | 'conflict' | 'related' | 'unrelated'; @@ -22,24 +38,60 @@ export class SuggestionEngine { classification: ClassificationResult, context: SuggestContext ): Promise { + if ( + classification.category === 'other' && + classification.docType === 'other' && + classification.confidence <= 0 + ) { + return makeFlagAction(filePath, context.proposedArchivePath, '自动分类失败,等待人工复查'); + } + const prompt = buildSuggestPrompt({ filePath, contentPreview: context.contentPreview, classification, dedupRelation: context.dedupRelation, - relatedPath: context.relatedPath, - proposedArchivePath: context.proposedArchivePath, + relatedPath: context.relatedPath ? basename(context.relatedPath) : undefined, }); - const text = await this.client.complete(prompt, '你是一名归档策略专家,只输出 JSON。'); + let text: string; + try { + text = await this.client.completeJson( + prompt, + '你是一名归档策略专家,只通过结构化 JSON 返回决策。', + SUGGEST_RESPONSE_SCHEMA + ); + } catch (error) { + if (!(error instanceof LlmStructuredOutputError)) { + throw error; + } + logger.warn( + { + fileName: basename(filePath), + errorType: error instanceof Error ? error.name : typeof error, + }, + 'LLM 建议请求失败' + ); + return makeFlagAction( + filePath, + context.proposedArchivePath, + 'LLM 建议请求失败,等待人工复查' + ); + } const parsed = parseSuggestResponse(text); if (parsed) { + const type = parsed.needsReview ? 'flag' : parsed.type; return { id: makeActionId(filePath), sourcePath: filePath, - type: parsed.type, + type, reason: parsed.rationale, - targetPath: parsed.targetPath ?? context.proposedArchivePath, + targetPath: + type === 'ignore' + ? undefined + : type === 'flag' + ? toFlaggedArchivePath(context.proposedArchivePath) + : context.proposedArchivePath, relatedEntryId: context.relatedPath, risk: parsed.risk, confidence: parsed.confidence, @@ -47,22 +99,28 @@ export class SuggestionEngine { }; } - logger.warn({ filePath, response: text.slice(0, 10000) }, 'LLM 建议解析失败'); - - // 解析失败时回退为 flag,但仍复制到 flagged 目录 - return { - id: makeActionId(filePath), - sourcePath: filePath, - type: 'flag', - reason: 'LLM 建议解析失败,复制到 flagged 目录等待复查', - targetPath: context.proposedArchivePath, - risk: 'high', - confidence: 0, - createdAt: Date.now(), - }; + logger.warn({ fileName: basename(filePath), responseLength: text.length }, 'LLM 建议解析失败'); + return makeFlagAction(filePath, context.proposedArchivePath, 'LLM 建议解析失败,等待人工复查'); } } +function makeFlagAction( + filePath: string, + proposedArchivePath: string, + reason: string +): ArchiveAction { + return { + id: makeActionId(filePath), + sourcePath: filePath, + type: 'flag', + reason, + targetPath: toFlaggedArchivePath(proposedArchivePath), + risk: 'high', + confidence: 0, + createdAt: Date.now(), + }; +} + function makeActionId(filePath: string): string { return createHash('sha256').update(`${filePath}:${Date.now()}`).digest('hex').slice(0, 16); } diff --git a/src/advance/archiver/adapters/builtin-provider-adapter.ts b/src/advance/archiver/adapters/builtin-provider-adapter.ts new file mode 100644 index 0000000..48217c4 --- /dev/null +++ b/src/advance/archiver/adapters/builtin-provider-adapter.ts @@ -0,0 +1,133 @@ +import type { + ArchiverProviderAdapter, + ArchiverProviderContextRequest, + ArchiverProviderContextResult, + ArchiverProviderDescriptor, + ArchiverProviderOverride, + ArchiverProviderProbeResult, + ArchiverProviderQueryRequest, + ArchiverProviderQueryResult, + ArchiverProviderRuntime, + ArchiverProviderSyncContext, + ArchiverProviderSyncResult, +} from '../provider-types.js'; +import { + capKnowledgeItems, + scoreKnowledgeText, + tokenizeKnowledgeQuery, +} from '../provider-query-utils.js'; +import { loadState } from '../../classic/runners/shared/state-utils.js'; +import type { ProjectKnowledgeItem } from '../../classic/memory/types.js'; + +export class BuiltinProviderAdapter implements ArchiverProviderAdapter { + readonly descriptor: ArchiverProviderDescriptor = { + id: 'builtin', + displayName: '内置知识提炼', + description: '提炼项目文档、约定、领域知识和长期维护风险,写入共享记忆。', + homepage: '', + license: 'MIT', + kind: 'builtin', + automation: 'full', + placements: ['primary', 'fallback', 'enricher'], + capabilities: ['documents', 'query'], + autoSelect: true, + selectionPriority: 1000, + }; + + async probe( + _context: ArchiverProviderSyncContext, + _runtime: ArchiverProviderRuntime, + _override?: ArchiverProviderOverride + ): Promise { + return { + providerId: this.descriptor.id, + available: true, + readiness: 'ready', + prepared: true, + version: 'built-in', + }; + } + + async sync( + _context: ArchiverProviderSyncContext, + _runtime: ArchiverProviderRuntime, + _override?: ArchiverProviderOverride + ): Promise { + return { + providerId: this.descriptor.id, + success: true, + skipped: true, + message: '由 Archiver 内置分析阶段执行', + }; + } + + async loadContext( + context: ArchiverProviderSyncContext, + _runtime: ArchiverProviderRuntime, + request: ArchiverProviderContextRequest, + _override?: ArchiverProviderOverride + ): Promise { + const items = this.readKnowledgeItems(context); + const content = capKnowledgeItems( + items.map(entry => formatKnowledgeItem(entry.item)), + items.length || 1, + request.maxChars ?? 8000 + ).join('\n'); + return { + providerId: this.descriptor.id, + success: true, + content, + message: content ? undefined : '尚无内置项目知识', + }; + } + + async query( + context: ArchiverProviderSyncContext, + _runtime: ArchiverProviderRuntime, + request: ArchiverProviderQueryRequest, + _override?: ArchiverProviderOverride + ): Promise { + const tokens = tokenizeKnowledgeQuery(request.query); + const ranked = this.readKnowledgeItems(context) + .map(entry => ({ + item: entry.item, + score: scoreKnowledgeText( + [entry.item.category, entry.item.content, ...entry.item.sourceFiles].join(' '), + tokens + ), + })) + .filter(entry => tokens.length === 0 || entry.score > 0) + .sort((left, right) => right.score - left.score); + return { + providerId: this.descriptor.id, + success: true, + items: capKnowledgeItems( + ranked.map(entry => formatKnowledgeItem(entry.item)), + request.limit ?? 6, + request.maxChars ?? 8000 + ), + }; + } + + async isReady( + context: ArchiverProviderSyncContext, + _runtime: ArchiverProviderRuntime, + _override?: ArchiverProviderOverride + ): Promise { + return this.readKnowledgeItems(context).length > 0; + } + + private readKnowledgeItems( + context: ArchiverProviderSyncContext + ): Array<{ item: ProjectKnowledgeItem; updatedAt: number }> { + const state = loadState(context.project); + return Object.values(state.archiverState?.items ?? {}).sort( + (left, right) => right.updatedAt - left.updatedAt + ); + } +} + +function formatKnowledgeItem(item: ProjectKnowledgeItem): string { + const sources = item.sourceFiles.length > 0 ? `;来源:${item.sourceFiles.join(', ')}` : ''; + return `- [${item.category}/${item.confidence}] ${item.content}${sources}`; +} diff --git a/src/advance/archiver/adapters/cli-provider-adapter.ts b/src/advance/archiver/adapters/cli-provider-adapter.ts new file mode 100644 index 0000000..ef3d664 --- /dev/null +++ b/src/advance/archiver/adapters/cli-provider-adapter.ts @@ -0,0 +1,364 @@ +import type { + ArchiverProviderAdapter, + ArchiverProviderDescriptor, + ArchiverProviderLaunchPreset, + ArchiverProviderManagedInvocation, + ArchiverProviderOverride, + ArchiverProviderPrepareResult, + ArchiverProviderProbeResult, + ArchiverProviderRuntime, + ArchiverProviderSyncContext, + ArchiverProviderSyncResult, +} from '../provider-types.js'; +import type { ProviderShellRequest, ProviderShellResult } from '../provider-shell.js'; + +export interface ProviderInvocation { + id?: string; + displayName?: string; + executable: string; + argsPrefix: string[]; + env: Record; + inheritEnv: string[]; + managedVersion?: string; +} + +export interface ProviderCommandSpec extends Omit< + ProviderShellRequest, + 'executable' | 'args' | 'env' | 'inheritEnv' | 'timeoutMs' +> { + args?: readonly string[]; + env?: Record; + inheritEnv?: readonly string[]; + /** Adapter 给出的默认超时,内部覆盖只能收紧或在上限内调整。 */ + timeoutMs?: number; + /** 对内部超时覆盖设置上限,适合轻量探测命令。 */ + timeoutCapMs?: number; +} + +interface ProviderInvocationSelection { + invocation?: ProviderInvocation; + result: ProviderShellResult; + attempts: string[]; +} + +/** CLI Provider 基类,统一自动启动器探测、结构化命令和版本检查。 */ +export abstract class CliProviderAdapter implements ArchiverProviderAdapter { + abstract readonly descriptor: ArchiverProviderDescriptor; + + private readonly invocationCache = new Map(); + + abstract sync( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + override?: ArchiverProviderOverride + ): Promise; + + protected getVersionArgs(): string[] { + return ['--version']; + } + + async prepare( + _context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + override?: ArchiverProviderOverride + ): Promise { + if (hasExplicitInvocationOverride(override)) { + return { + providerId: this.descriptor.id, + success: false, + prepared: false, + message: '显式启动覆盖不参与系统自动准备', + }; + } + if (!runtime.provisioner || !this.descriptor.managedRuntime) { + return { + providerId: this.descriptor.id, + success: false, + prepared: false, + message: 'Provider 未声明可自动准备的托管运行时', + }; + } + return runtime.provisioner.prepare(this.descriptor); + } + + async probe( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + override?: ArchiverProviderOverride + ): Promise { + const selection = await this.selectInvocation(context, runtime, override); + if (!selection.invocation) { + return { + providerId: this.descriptor.id, + available: false, + readiness: this.descriptor.managedRuntime ? 'preparable' : 'unavailable', + prepared: false, + message: + selection.attempts.length > 0 + ? `自动探测失败:${selection.attempts.join(';')}` + : formatShellFailure(selection.result), + }; + } + + const label = selection.invocation.displayName ?? selection.invocation.executable; + return { + providerId: this.descriptor.id, + available: true, + readiness: 'ready', + prepared: Boolean(selection.invocation.managedVersion), + executable: selection.result.executable ?? selection.invocation.executable, + version: + selection.invocation.managedVersion ?? + firstNonEmptyLine(selection.result.stdout, selection.result.stderr), + message: selection.invocation.managedVersion + ? `已自动选择托管运行时:${label}` + : `已自动选择:${label}`, + }; + } + + /** 使用已自动选择的启动器执行 Provider 语义命令。 */ + protected async runCommand( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + override: ArchiverProviderOverride | undefined, + command: ProviderCommandSpec + ): Promise { + const explicitInvocation = hasExplicitInvocationOverride(override); + let invocation = explicitInvocation + ? this.buildInvocation(override) + : this.invocationCache.get(this.getInvocationCacheKey(context)); + if (!invocation) { + const selection = await this.selectInvocation(context, runtime, override); + invocation = selection.invocation; + if (!invocation) return selection.result; + } + + const result = await runtime.shell.run(this.buildCommandRequest(override, command, invocation)); + if (result.failureKind === 'not-found' && !explicitInvocation) { + this.invocationCache.delete(this.getInvocationCacheKey(context)); + } + return result; + } + + /** 将 Provider 语义参数与已选启动方式合并为结构化命令请求。 */ + protected buildCommandRequest( + override: ArchiverProviderOverride | undefined, + command: ProviderCommandSpec, + invocation: ProviderInvocation = this.buildInvocation(override) + ): ProviderShellRequest { + const { + args = [], + env, + inheritEnv = [], + timeoutMs: defaultTimeoutMs, + timeoutCapMs, + ...request + } = command; + const configuredTimeoutMs = override?.timeoutMs ?? defaultTimeoutMs; + const timeoutMs = + configuredTimeoutMs === undefined + ? undefined + : timeoutCapMs === undefined + ? configuredTimeoutMs + : Math.min(configuredTimeoutMs, timeoutCapMs); + + return { + ...request, + executable: invocation.executable, + args: [...invocation.argsPrefix, ...args], + inheritEnv: uniqueStrings([...invocation.inheritEnv, ...inheritEnv]), + env: { + ...env, + ...invocation.env, + }, + timeoutMs, + }; + } + + protected buildInvocation(override?: ArchiverProviderOverride): ProviderInvocation { + const requestedPreset = override?.launchPreset?.trim() || this.descriptor.defaultLaunchPreset; + const preset = requestedPreset + ? this.descriptor.launchPresets?.find(candidate => candidate.id === requestedPreset) + : undefined; + if (requestedPreset && !preset) { + throw new Error(`Provider ${this.descriptor.id} 不支持启动预设 ${requestedPreset}`); + } + + const executable = + override?.executable?.trim() || preset?.executable || this.descriptor.defaultExecutable; + if (!executable) { + throw new Error(`Provider ${this.descriptor.id} 未声明可执行文件`); + } + return this.createInvocation(preset, override, executable); + } + + private async selectInvocation( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + override?: ArchiverProviderOverride + ): Promise { + const managed = + !hasExplicitInvocationOverride(override) && runtime.provisioner + ? await runtime.provisioner.resolve(this.descriptor) + : null; + const candidates = this.buildInvocationCandidates(override, managed?.invocation); + const attempts: string[] = []; + let lastResult = unavailableShellResult([], `Provider ${this.descriptor.id} 未声明启动方式`); + + for (const invocation of candidates) { + const result = await runtime.shell.run( + this.buildCommandRequest( + override, + { + args: this.getVersionArgs(), + cwd: context.project.rootPath, + timeoutMs: 15000, + timeoutCapMs: 15000, + maxOutputBytes: 16 * 1024, + }, + invocation + ) + ); + lastResult = result; + const label = invocation.displayName ?? invocation.executable; + if (result.success) { + this.cacheInvocation(context, override, invocation); + return { invocation, result, attempts }; + } + + attempts.push(`${label}: ${formatShellFailure(result)}`); + } + return { result: lastResult, attempts }; + } + + private buildInvocationCandidates( + override?: ArchiverProviderOverride, + managed?: ArchiverProviderManagedInvocation + ): ProviderInvocation[] { + if (hasExplicitInvocationOverride(override)) return [this.buildInvocation(override)]; + + const candidates: ProviderInvocation[] = managed + ? [ + { + id: 'managed', + displayName: '系统托管运行时', + executable: managed.executable, + argsPrefix: [...managed.argsPrefix], + env: { ...(managed.env ?? {}) }, + inheritEnv: [...(managed.inheritEnv ?? [])], + managedVersion: managed.version, + }, + ] + : []; + candidates.push( + ...(this.descriptor.launchPresets ?? []).map(preset => + this.createInvocation(preset, override, preset.executable) + ) + ); + if (this.descriptor.defaultExecutable) { + candidates.push({ + id: 'default', + displayName: '默认命令', + executable: this.descriptor.defaultExecutable, + argsPrefix: [], + env: { ...(override?.env ?? {}) }, + inheritEnv: [...(override?.inheritEnv ?? [])], + }); + } + return uniqueInvocations(candidates); + } + + private createInvocation( + preset: ArchiverProviderLaunchPreset | undefined, + override: ArchiverProviderOverride | undefined, + executable: string + ): ProviderInvocation { + return { + id: preset?.id, + displayName: preset?.displayName, + executable, + argsPrefix: [ + ...(override?.argsPrefix && override.argsPrefix.length > 0 + ? override.argsPrefix + : (preset?.argsPrefix ?? [])), + ], + env: { ...(override?.env ?? {}) }, + inheritEnv: uniqueStrings([...(preset?.inheritEnv ?? []), ...(override?.inheritEnv ?? [])]), + }; + } + + private cacheInvocation( + context: ArchiverProviderSyncContext, + override: ArchiverProviderOverride | undefined, + invocation: ProviderInvocation + ): void { + if (!hasExplicitInvocationOverride(override)) { + this.invocationCache.set(this.getInvocationCacheKey(context), invocation); + } + } + + private getInvocationCacheKey(context: ArchiverProviderSyncContext): string { + return context.project.rootPath; + } +} + +function hasExplicitInvocationOverride(override?: ArchiverProviderOverride): boolean { + return Boolean( + override?.launchPreset?.trim() || + override?.executable?.trim() || + (override?.argsPrefix && override.argsPrefix.length > 0) || + (override?.inheritEnv && override.inheritEnv.length > 0) || + (override?.env && Object.keys(override.env).length > 0) + ); +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} + +function uniqueInvocations(invocations: ProviderInvocation[]): ProviderInvocation[] { + const seen = new Set(); + return invocations.filter(invocation => { + const key = JSON.stringify([ + invocation.executable, + invocation.argsPrefix, + invocation.inheritEnv, + invocation.env, + invocation.managedVersion, + ]); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function unavailableShellResult(args: string[], message: string): ProviderShellResult { + return { + success: false, + args, + exitCode: null, + signal: null, + stdout: '', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + durationMs: 0, + failureKind: 'not-found', + error: message, + }; +} + +export function firstNonEmptyLine(...values: string[]): string | undefined { + for (const value of values) { + const line = value + .split(/\r?\n/) + .map(item => item.trim()) + .find(Boolean); + if (line) return line; + } + return undefined; +} + +export function formatShellFailure(result: ProviderShellResult): string { + return result.error ?? firstNonEmptyLine(result.stderr, result.stdout) ?? '未知错误'; +} diff --git a/src/advance/archiver/adapters/codebase-memory-provider-adapter.ts b/src/advance/archiver/adapters/codebase-memory-provider-adapter.ts new file mode 100644 index 0000000..f301d90 --- /dev/null +++ b/src/advance/archiver/adapters/codebase-memory-provider-adapter.ts @@ -0,0 +1,299 @@ +import { existsSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { + ArchiverProviderContextRequest, + ArchiverProviderContextResult, + ArchiverProviderDescriptor, + ArchiverProviderOverride, + ArchiverProviderQueryRequest, + ArchiverProviderQueryResult, + ArchiverProviderRuntime, + ArchiverProviderSyncContext, + ArchiverProviderSyncResult, +} from '../provider-types.js'; +import { PYTHON_LAUNCH_ENVIRONMENT_KEYS, UV_LAUNCH_ENVIRONMENT_KEYS } from '../provider-launch.js'; +import { formatProviderJsonItems, parseProviderJson } from '../provider-query-utils.js'; +import { CliProviderAdapter, formatShellFailure } from './cli-provider-adapter.js'; + +export class CodebaseMemoryProviderAdapter extends CliProviderAdapter { + readonly descriptor: ArchiverProviderDescriptor = { + id: 'codebase-memory-mcp', + displayName: 'codebase-memory-mcp', + description: '为代码库建立可查询索引,作为结构探索和影响分析的回退 Provider。', + homepage: 'https://github.com/DeusData/codebase-memory-mcp', + license: 'MIT', + kind: 'cli', + automation: 'full', + placements: ['primary', 'fallback', 'enricher'], + capabilities: ['code-structure', 'query', 'impact-analysis'], + autoSelect: true, + selectionPriority: 90, + defaultExecutable: 'codebase-memory-mcp', + defaultLaunchPreset: 'installed', + managedRuntime: { + kind: 'npm-package', + packageName: 'codebase-memory-mcp', + version: '0.10.3', + entrypoint: 'codebase-memory-mcp', + }, + launchPresets: [ + { + id: 'installed', + displayName: '已安装命令', + description: '使用 PATH 中已有的原生或包装命令。', + executable: 'codebase-memory-mcp', + argsPrefix: [], + }, + { + id: 'uvx', + displayName: 'uvx 临时运行', + description: '通过 PyPI 包临时运行,首次使用可能下载原生运行时。', + executable: 'uvx', + argsPrefix: ['codebase-memory-mcp'], + inheritEnv: [...UV_LAUNCH_ENVIRONMENT_KEYS], + }, + { + id: 'python-module', + displayName: 'python -m', + description: '使用当前 Python 环境中的包装模块。', + executable: 'python', + argsPrefix: ['-m', 'codebase_memory_mcp'], + inheritEnv: [...PYTHON_LAUNCH_ENVIRONMENT_KEYS], + }, + ], + }; + + async sync( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + override?: ArchiverProviderOverride + ): Promise { + const dataRoot = join(context.providerDataRoot, this.descriptor.id); + await mkdir(dataRoot, { recursive: true }); + + const result = await this.runCommand(context, runtime, override, { + args: ['cli', 'index_repository', '--repo-path', context.project.rootPath], + cwd: dataRoot, + env: { + CBM_ALLOWED_ROOT: context.project.rootPath, + CBM_CACHE_DIR: dataRoot, + CBM_LOG_LEVEL: 'warn', + }, + timeoutMs: 30 * 60 * 1000, + }); + + if (!result.success) { + return { + providerId: this.descriptor.id, + success: false, + message: formatShellFailure(result), + }; + } + return { + providerId: this.descriptor.id, + success: true, + message: 'codebase-memory-mcp 索引已更新', + artifacts: [toArtifactPath(this.descriptor.id)], + metadata: { storage: 'archive-managed-cache' }, + }; + } + + async loadContext( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + request: ArchiverProviderContextRequest, + override?: ArchiverProviderOverride + ): Promise { + const projectName = await this.resolveProjectName(context, runtime, override); + if (!projectName) { + return { + providerId: this.descriptor.id, + success: false, + message: '无法从 codebase-memory-mcp 解析当前项目索引', + }; + } + const result = await this.runTool( + context, + runtime, + override, + 'get_architecture', + { project: projectName }, + 60_000 + ); + if (!result.success) { + return { + providerId: this.descriptor.id, + success: false, + message: formatShellFailure(result), + }; + } + try { + return { + providerId: this.descriptor.id, + success: true, + content: formatProviderJsonItems( + parseProviderJson(result.stdout), + 12, + request.maxChars ?? 8000 + ).join('\n'), + }; + } catch (error) { + return { + providerId: this.descriptor.id, + success: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + async query( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + request: ArchiverProviderQueryRequest, + override?: ArchiverProviderOverride + ): Promise { + const projectName = await this.resolveProjectName(context, runtime, override); + if (!projectName) { + return { + providerId: this.descriptor.id, + success: false, + items: [], + message: '无法从 codebase-memory-mcp 解析当前项目索引', + }; + } + const result = await this.runTool( + context, + runtime, + override, + 'semantic_query', + { + project: projectName, + query: request.query, + limit: request.limit ?? 6, + }, + 90_000 + ); + if (!result.success) { + return { + providerId: this.descriptor.id, + success: false, + items: [], + message: formatShellFailure(result), + }; + } + try { + return { + providerId: this.descriptor.id, + success: true, + items: formatProviderJsonItems( + parseProviderJson(result.stdout), + request.limit ?? 6, + request.maxChars ?? 10000 + ), + }; + } catch (error) { + return { + providerId: this.descriptor.id, + success: false, + items: [], + message: error instanceof Error ? error.message : String(error), + }; + } + } + + async isReady( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + override?: ArchiverProviderOverride + ): Promise { + if (!existsSync(join(context.providerDataRoot, this.descriptor.id))) return false; + return (await this.resolveProjectName(context, runtime, override)) !== null; + } + + private async resolveProjectName( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + override?: ArchiverProviderOverride + ): Promise { + const result = await this.runTool( + context, + runtime, + override, + 'list_projects', + undefined, + 30_000 + ); + if (!result.success) return null; + try { + return findIndexedProjectName(parseProviderJson(result.stdout), context.project.rootPath); + } catch { + return null; + } + } + + private runTool( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + override: ArchiverProviderOverride | undefined, + tool: string, + input: Record | undefined, + timeoutMs: number + ) { + const dataRoot = join(context.providerDataRoot, this.descriptor.id); + return this.runCommand(context, runtime, override, { + args: ['cli', tool], + cwd: dataRoot, + env: { + CBM_ALLOWED_ROOT: context.project.rootPath, + CBM_CACHE_DIR: dataRoot, + CBM_LOG_LEVEL: 'warn', + }, + stdin: input ? `${JSON.stringify(input)}\n` : undefined, + timeoutMs, + maxOutputBytes: 512 * 1024, + }); + } +} + +function findIndexedProjectName(value: unknown, rootPath: string): string | null { + const records = collectRecords(value); + const normalizedRoot = normalizeProviderPath(rootPath); + const pathKeys = ['root_path', 'repo_path', 'path', 'rootPath', 'repoPath']; + const nameKeys = ['name', 'project', 'project_name', 'projectName', 'id']; + for (const record of records) { + const candidatePath = pathKeys + .map(key => record[key]) + .find(item => typeof item === 'string') as string | undefined; + if (!candidatePath || normalizeProviderPath(candidatePath) !== normalizedRoot) continue; + const candidateName = nameKeys + .map(key => record[key]) + .find(item => typeof item === 'string') as string | undefined; + if (candidateName?.trim()) return candidateName.trim(); + } + if (records.length === 1) { + const candidateName = nameKeys + .map(key => records[0][key]) + .find(item => typeof item === 'string') as string | undefined; + return candidateName?.trim() || null; + } + return null; +} + +function collectRecords(value: unknown): Array> { + if (Array.isArray(value)) return value.flatMap(collectRecords); + if (!value || typeof value !== 'object') return []; + const record = value as Record; + const nested = Object.values(record).flatMap(item => + Array.isArray(item) || (item && typeof item === 'object') ? collectRecords(item) : [] + ); + return [record, ...nested]; +} + +function normalizeProviderPath(value: string): string { + return value.trim().replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); +} + +function toArtifactPath(...segments: string[]): string { + return segments.join('/').replace(/\\/g, '/'); +} diff --git a/src/advance/archiver/adapters/graphify-provider-adapter.ts b/src/advance/archiver/adapters/graphify-provider-adapter.ts new file mode 100644 index 0000000..18f7be0 --- /dev/null +++ b/src/advance/archiver/adapters/graphify-provider-adapter.ts @@ -0,0 +1,353 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readFile, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { + ArchiverProviderContextRequest, + ArchiverProviderContextResult, + ArchiverProviderDescriptor, + ArchiverProviderOverride, + ArchiverProviderQueryRequest, + ArchiverProviderQueryResult, + ArchiverProviderRuntime, + ArchiverProviderSyncContext, + ArchiverProviderSyncResult, +} from '../provider-types.js'; +import { PYTHON_LAUNCH_ENVIRONMENT_KEYS, UV_LAUNCH_ENVIRONMENT_KEYS } from '../provider-launch.js'; +import { + capKnowledgeItems, + scoreKnowledgeText, + tokenizeKnowledgeQuery, +} from '../provider-query-utils.js'; +import { CliProviderAdapter, formatShellFailure } from './cli-provider-adapter.js'; + +const MAX_GRAPH_BYTES = 128 * 1024 * 1024; + +export class GraphifyProviderAdapter extends CliProviderAdapter { + readonly descriptor: ArchiverProviderDescriptor = { + id: 'graphify', + displayName: 'Graphify', + description: '基于 AST 构建代码结构图,为查询、影响分析和架构探索提供支撑。', + homepage: 'https://github.com/Graphify-Labs/graphify', + license: 'MIT', + kind: 'cli', + automation: 'full', + placements: ['primary', 'fallback', 'enricher'], + capabilities: ['code-structure', 'documents', 'query', 'impact-analysis'], + autoSelect: true, + selectionPriority: 100, + defaultExecutable: 'graphify', + defaultLaunchPreset: 'installed', + managedRuntime: { + kind: 'python-package', + packageName: 'graphifyy', + version: '0.9.42', + entrypoint: 'graphify', + }, + launchPresets: [ + { + id: 'installed', + displayName: '已安装命令', + description: '使用 PATH 中已有的 graphify 命令。', + executable: 'graphify', + argsPrefix: [], + }, + { + id: 'uvx', + displayName: 'uvx 临时运行', + description: '通过 uvx --from graphifyy graphify 运行,首次使用可能下载包。', + executable: 'uvx', + argsPrefix: ['--from', 'graphifyy', 'graphify'], + inheritEnv: [...UV_LAUNCH_ENVIRONMENT_KEYS], + }, + { + id: 'python-module', + displayName: 'python -m', + description: '使用当前 Python 环境中的 graphify 模块。', + executable: 'python', + argsPrefix: ['-m', 'graphify'], + inheritEnv: [...PYTHON_LAUNCH_ENVIRONMENT_KEYS], + }, + ], + options: [ + { + key: 'codeOnly', + displayName: '仅索引代码', + description: + '推荐开启:代码结构完全本地处理,文档知识继续由内置 Archiver 提炼。关闭后 Graphify 可能需要额外模型凭据。', + type: 'boolean', + defaultValue: true, + }, + ], + }; + + async sync( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + override?: ArchiverProviderOverride + ): Promise { + const dataRoot = join(context.providerDataRoot, this.descriptor.id); + const graphRoot = join(dataRoot, 'graphify-out'); + const graphPath = join(graphRoot, 'graph.json'); + await mkdir(dataRoot, { recursive: true }); + + const initialized = existsSync(graphPath); + const args = [ + 'extract', + context.project.rootPath, + '--out', + dataRoot, + '--no-viz', + '--no-cluster', + ...(override?.options?.codeOnly === false ? [] : ['--code-only']), + ]; + const result = await this.runCommand(context, runtime, override, { + args, + cwd: dataRoot, + env: { + GRAPHIFY_NO_TIPS: '1', + }, + timeoutMs: 30 * 60 * 1000, + }); + + if (!result.success) { + return { + providerId: this.descriptor.id, + success: false, + message: formatShellFailure(result), + }; + } + return { + providerId: this.descriptor.id, + success: true, + message: initialized ? 'Graphify 知识图谱已更新' : 'Graphify 知识图谱已创建', + artifacts: [toArtifactPath(this.descriptor.id, 'graphify-out', 'graph.json')], + metadata: { mode: initialized ? 'incremental-extract' : 'extract' }, + }; + } + + async loadContext( + context: ArchiverProviderSyncContext, + _runtime: ArchiverProviderRuntime, + request: ArchiverProviderContextRequest, + _override?: ArchiverProviderOverride + ): Promise { + const outputRoot = this.getOutputRoot(context); + const maxChars = request.maxChars ?? 8000; + const report = await readOptionalText(join(outputRoot, 'GRAPH_REPORT.md'), maxChars); + const wikiIndex = await readOptionalText(join(outputRoot, 'wiki', 'index.md'), maxChars); + if (report || wikiIndex) { + return { + providerId: this.descriptor.id, + success: true, + content: capKnowledgeItems([report, wikiIndex], 2, maxChars).join('\n\n'), + }; + } + + const graph = await this.readGraph(context); + if (!graph) { + return { + providerId: this.descriptor.id, + success: false, + message: 'Graphify 图谱不存在或不可读取', + }; + } + return { + providerId: this.descriptor.id, + success: true, + content: buildGraphSummary(graph, maxChars), + }; + } + + async query( + context: ArchiverProviderSyncContext, + _runtime: ArchiverProviderRuntime, + request: ArchiverProviderQueryRequest, + _override?: ArchiverProviderOverride + ): Promise { + const graph = await this.readGraph(context); + if (!graph) { + return { + providerId: this.descriptor.id, + success: false, + items: [], + message: 'Graphify 图谱不存在或不可读取', + }; + } + const tokens = tokenizeKnowledgeQuery(request.query); + const adjacency = buildAdjacency(graph); + const nodesById = new Map( + graph.nodes.map(node => [normalizeNodeId(node.id), node] as const).filter(entry => entry[0]) + ); + const ranked = graph.nodes + .map(node => { + const nodeId = normalizeNodeId(node.id); + const relations = nodeId ? (adjacency.get(nodeId) ?? []) : []; + const text = [ + ...Object.values(node).map(formatScalar), + ...relations.flatMap(relation => [relation.relation, relation.otherId]), + ] + .filter(Boolean) + .join(' '); + return { + node, + nodeId, + relations, + score: scoreKnowledgeText(text, tokens) + Math.log2(relations.length + 1), + }; + }) + .filter(entry => tokens.length === 0 || entry.score > Math.log2(entry.relations.length + 1)) + .sort((left, right) => right.score - left.score); + const items = ranked.map(entry => formatGraphNode(entry.node, entry.relations, nodesById)); + return { + providerId: this.descriptor.id, + success: true, + items: capKnowledgeItems(items, request.limit ?? 6, request.maxChars ?? 10000), + }; + } + + async isReady( + context: ArchiverProviderSyncContext, + _runtime: ArchiverProviderRuntime, + _override?: ArchiverProviderOverride + ): Promise { + return (await this.readGraph(context)) !== null; + } + + private getOutputRoot(context: ArchiverProviderSyncContext): string { + return join(context.providerDataRoot, this.descriptor.id, 'graphify-out'); + } + + private async readGraph(context: ArchiverProviderSyncContext): Promise { + const graphPath = join(this.getOutputRoot(context), 'graph.json'); + try { + const info = await stat(graphPath); + if (!info.isFile() || info.size > MAX_GRAPH_BYTES) return null; + const parsed = JSON.parse(await readFile(graphPath, 'utf8')) as Partial; + if (!Array.isArray(parsed.nodes) || !Array.isArray(parsed.links)) return null; + return { nodes: parsed.nodes, links: parsed.links }; + } catch { + return null; + } + } +} + +interface GraphifyNode extends Record { + id?: unknown; + label?: unknown; + file_type?: unknown; + source_file?: unknown; + source_location?: unknown; +} + +interface GraphifyLink extends Record { + source?: unknown; + target?: unknown; + relation?: unknown; + confidence?: unknown; +} + +interface GraphifyGraph { + nodes: GraphifyNode[]; + links: GraphifyLink[]; +} + +interface GraphRelation { + relation: string; + otherId: string; + direction: 'in' | 'out'; +} + +async function readOptionalText(path: string, maxChars: number): Promise { + try { + return (await readFile(path, 'utf8')).trim().slice(0, maxChars); + } catch { + return ''; + } +} + +function buildGraphSummary(graph: GraphifyGraph, maxChars: number): string { + const adjacency = buildAdjacency(graph); + const topNodes = graph.nodes + .map(node => ({ node, degree: adjacency.get(normalizeNodeId(node.id))?.length ?? 0 })) + .sort((left, right) => right.degree - left.degree) + .slice(0, 12) + .map(({ node, degree }) => `${formatNodeLabel(node)}(关联 ${degree})`); + const sourceFiles = new Set( + graph.nodes.map(node => formatScalar(node.source_file)).filter(Boolean) + ); + return capKnowledgeItems( + [ + `图谱节点 ${graph.nodes.length} 个,关系 ${graph.links.length} 条,来源文件 ${sourceFiles.size} 个。`, + topNodes.length > 0 ? `高连接节点:${topNodes.join('、')}` : '', + ], + 2, + maxChars + ).join('\n'); +} + +function buildAdjacency(graph: GraphifyGraph): Map { + const adjacency = new Map(); + for (const link of graph.links) { + const source = normalizeNodeId(link.source); + const target = normalizeNodeId(link.target); + if (!source || !target) continue; + const relation = formatScalar(link.relation) || 'related'; + appendRelation(adjacency, source, { relation, otherId: target, direction: 'out' }); + appendRelation(adjacency, target, { relation, otherId: source, direction: 'in' }); + } + return adjacency; +} + +function appendRelation( + adjacency: Map, + nodeId: string, + relation: GraphRelation +): void { + const relations = adjacency.get(nodeId) ?? []; + relations.push(relation); + adjacency.set(nodeId, relations); +} + +function formatGraphNode( + node: GraphifyNode, + relations: GraphRelation[], + nodesById: Map +): string { + const type = formatScalar(node.file_type); + const source = [formatScalar(node.source_file), formatScalar(node.source_location)] + .filter(Boolean) + .join(':'); + const relationText = relations + .slice(0, 4) + .map(relation => { + const other = nodesById.get(relation.otherId); + const arrow = relation.direction === 'out' ? '→' : '←'; + return `${relation.relation}${arrow}${other ? formatNodeLabel(other) : relation.otherId}`; + }) + .join(','); + return [formatNodeLabel(node), type ? `[${type}]` : '', source, relationText] + .filter(Boolean) + .join(';'); +} + +function formatNodeLabel(node: GraphifyNode): string { + return formatScalar(node.label) || normalizeNodeId(node.id) || '未命名节点'; +} + +function normalizeNodeId(value: unknown): string { + if (typeof value === 'string' || typeof value === 'number') return String(value); + if (value && typeof value === 'object') { + return normalizeNodeId((value as Record).id); + } + return ''; +} + +function formatScalar(value: unknown): string { + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return ''; +} + +function toArtifactPath(...segments: string[]): string { + return segments.join('/').replace(/\\/g, '/'); +} diff --git a/src/advance/archiver/adapters/repowise-provider-adapter.ts b/src/advance/archiver/adapters/repowise-provider-adapter.ts new file mode 100644 index 0000000..855c9a0 --- /dev/null +++ b/src/advance/archiver/adapters/repowise-provider-adapter.ts @@ -0,0 +1,158 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { + ArchiverProviderDescriptor, + ArchiverProviderOverride, + ArchiverProviderQueryRequest, + ArchiverProviderQueryResult, + ArchiverProviderRuntime, + ArchiverProviderSyncContext, + ArchiverProviderSyncResult, +} from '../provider-types.js'; +import { UV_LAUNCH_ENVIRONMENT_KEYS } from '../provider-launch.js'; +import { formatProviderJsonItems, parseProviderJson } from '../provider-query-utils.js'; +import { CliProviderAdapter, formatShellFailure } from './cli-provider-adapter.js'; + +export class RepowiseProviderAdapter extends CliProviderAdapter { + readonly descriptor: ArchiverProviderDescriptor = { + id: 'repowise', + displayName: 'Repowise', + description: '在用户已初始化的实例上补充 Git 历史、代码健康度和影响分析。', + homepage: 'https://github.com/repowise-dev/repowise', + license: 'AGPL-3.0-or-later', + kind: 'cli', + automation: 'managed', + placements: ['enricher'], + capabilities: ['code-structure', 'query', 'impact-analysis', 'git-history', 'code-health'], + autoSelect: true, + selectionPriority: 50, + defaultExecutable: 'repowise', + defaultLaunchPreset: 'installed', + managedRuntime: { + kind: 'python-package', + packageName: 'repowise', + version: '0.42.0', + entrypoint: 'repowise', + }, + launchPresets: [ + { + id: 'installed', + displayName: '已安装命令', + description: '使用 PATH 中已有的 repowise 命令。', + executable: 'repowise', + argsPrefix: [], + }, + { + id: 'uvx', + displayName: 'uvx 临时运行', + description: '通过 PyPI 包临时运行,首次使用可能下载包。', + executable: 'uvx', + argsPrefix: ['repowise'], + inheritEnv: [...UV_LAUNCH_ENVIRONMENT_KEYS], + }, + ], + licenseNotice: '该 Provider 使用 AGPL-3.0-or-later;启用前请确认部署与分发方式符合许可证要求。', + }; + + async sync( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + override?: ArchiverProviderOverride + ): Promise { + if (!existsSync(join(context.project.rootPath, '.repowise'))) { + return { + providerId: this.descriptor.id, + success: true, + skipped: true, + message: '项目尚未初始化 Repowise,按 managed 模式跳过', + }; + } + + const result = await this.runCommand(context, runtime, override, { + args: ['update', context.project.rootPath], + cwd: context.project.rootPath, + env: { + DO_NOT_TRACK: '1', + }, + timeoutMs: 30 * 60 * 1000, + }); + if (!result.success) { + return { + providerId: this.descriptor.id, + success: false, + message: formatShellFailure(result), + }; + } + return { + providerId: this.descriptor.id, + success: true, + message: 'Repowise 知识库已更新', + artifacts: ['.repowise'], + }; + } + + async query( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + request: ArchiverProviderQueryRequest, + override?: ArchiverProviderOverride + ): Promise { + if (!existsSync(join(context.project.rootPath, '.repowise'))) { + return { + providerId: this.descriptor.id, + success: true, + items: [], + message: '项目尚未初始化 Repowise', + }; + } + const result = await this.runCommand(context, runtime, override, { + args: [ + 'search', + '--format', + 'json', + '--limit', + String(request.limit ?? 6), + request.query, + context.project.rootPath, + ], + cwd: context.project.rootPath, + env: { DO_NOT_TRACK: '1' }, + timeoutMs: 90_000, + maxOutputBytes: 512 * 1024, + }); + if (!result.success) { + return { + providerId: this.descriptor.id, + success: false, + items: [], + message: formatShellFailure(result), + }; + } + try { + return { + providerId: this.descriptor.id, + success: true, + items: formatProviderJsonItems( + parseProviderJson(result.stdout), + request.limit ?? 6, + request.maxChars ?? 10000 + ), + }; + } catch (error) { + return { + providerId: this.descriptor.id, + success: false, + items: [], + message: error instanceof Error ? error.message : String(error), + }; + } + } + + async isReady( + context: ArchiverProviderSyncContext, + _runtime: ArchiverProviderRuntime, + _override?: ArchiverProviderOverride + ): Promise { + return existsSync(join(context.project.rootPath, '.repowise')); + } +} diff --git a/src/advance/archiver/adapters/understand-anything-provider-adapter.ts b/src/advance/archiver/adapters/understand-anything-provider-adapter.ts new file mode 100644 index 0000000..ee07b69 --- /dev/null +++ b/src/advance/archiver/adapters/understand-anything-provider-adapter.ts @@ -0,0 +1,256 @@ +import { readFile, readdir, stat } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import type { + ArchiverProviderAdapter, + ArchiverProviderContextRequest, + ArchiverProviderContextResult, + ArchiverProviderDescriptor, + ArchiverProviderOverride, + ArchiverProviderPrepareResult, + ArchiverProviderProbeResult, + ArchiverProviderQueryRequest, + ArchiverProviderQueryResult, + ArchiverProviderRuntime, + ArchiverProviderSyncContext, + ArchiverProviderSyncResult, +} from '../provider-types.js'; +import { + capKnowledgeItems, + scoreKnowledgeText, + tokenizeKnowledgeQuery, +} from '../provider-query-utils.js'; + +const KNOWLEDGE_ROOT_NAMES = ['.understand-anything', '.ua'] as const; +const KNOWLEDGE_GRAPH_FILE_NAME = 'knowledge-graph.json'; +const KNOWLEDGE_FILE_EXTENSIONS = new Set(['.json', '.md', '.txt', '.yaml', '.yml']); +const MAX_KNOWLEDGE_FILE_BYTES = 4 * 1024 * 1024; +const MAX_KNOWLEDGE_FILES = 48; + +export class UnderstandAnythingProviderAdapter implements ArchiverProviderAdapter { + readonly descriptor: ArchiverProviderDescriptor = { + id: 'understand-anything', + displayName: 'Understand Anything', + description: '交互式 Agent Skill,可按需生成项目理解文档和结构化知识。', + homepage: 'https://github.com/Egonex-AI/Understand-Anything', + license: 'MIT', + kind: 'skill', + automation: 'manual', + placements: ['enricher'], + capabilities: ['code-structure', 'documents', 'query', 'interactive-skill'], + autoSelect: true, + managedRuntime: { + kind: 'git-skill', + repository: 'https://github.com/Egonex-AI/Understand-Anything.git', + revision: 'v2.9.0', + version: '2.9.0', + skillPath: 'understand-anything-plugin/skills/understand/SKILL.md', + }, + }; + + async prepare( + _context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + _override?: ArchiverProviderOverride + ): Promise { + if (!runtime.provisioner) { + return { + providerId: this.descriptor.id, + success: false, + prepared: false, + manual: true, + message: '系统未初始化 Provider 托管运行时', + }; + } + return runtime.provisioner.prepare(this.descriptor); + } + + async probe( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + _override?: ArchiverProviderOverride + ): Promise { + if (await this.hasKnowledgeArtifacts(context)) { + return { + providerId: this.descriptor.id, + available: true, + readiness: 'ready', + prepared: true, + message: '已读取项目理解产物;Skill 仅用于重新生成', + }; + } + const prepared = await runtime.provisioner?.resolve(this.descriptor); + if (prepared?.prepared) { + return { + providerId: this.descriptor.id, + available: false, + readiness: 'manual', + prepared: true, + version: prepared.version, + message: 'Skill 已自动准备;执行阶段需要 Agent 工作流调度', + }; + } + return { + providerId: this.descriptor.id, + available: false, + readiness: 'preparable', + prepared: false, + message: 'Skill 可由系统自动准备;运行阶段需要 Agent 工作流调度', + }; + } + + async sync( + context: ArchiverProviderSyncContext, + _runtime: ArchiverProviderRuntime, + _override?: ArchiverProviderOverride + ): Promise { + return { + providerId: this.descriptor.id, + success: true, + skipped: true, + message: (await this.hasKnowledgeArtifacts(context)) + ? '项目理解产物已存在,等待下一次 Agent 工作流更新' + : 'Skill 资源已准备,等待 Agent 工作流生成项目知识图谱', + }; + } + + async isReady( + context: ArchiverProviderSyncContext, + _runtime: ArchiverProviderRuntime, + _override?: ArchiverProviderOverride + ): Promise { + return this.hasKnowledgeArtifacts(context); + } + + async loadContext( + context: ArchiverProviderSyncContext, + _runtime: ArchiverProviderRuntime, + request: ArchiverProviderContextRequest, + _override?: ArchiverProviderOverride + ): Promise { + const documents = await this.readKnowledgeDocuments(context); + if (documents.length === 0) { + return { + providerId: this.descriptor.id, + success: false, + message: '尚无项目理解产物', + }; + } + return { + providerId: this.descriptor.id, + success: true, + content: capKnowledgeItems( + documents.map(document => formatDocument(document.path, document.content)), + documents.length, + request.maxChars ?? 8000 + ).join('\n\n'), + }; + } + + async query( + context: ArchiverProviderSyncContext, + _runtime: ArchiverProviderRuntime, + request: ArchiverProviderQueryRequest, + _override?: ArchiverProviderOverride + ): Promise { + const documents = await this.readKnowledgeDocuments(context); + const tokens = tokenizeKnowledgeQuery(request.query); + const ranked = documents + .map(document => ({ + document, + score: scoreKnowledgeText(`${document.path} ${document.content}`, tokens), + })) + .filter(entry => tokens.length === 0 || entry.score > 0) + .sort((left, right) => right.score - left.score) + .map(entry => formatDocument(entry.document.path, entry.document.content)); + return { + providerId: this.descriptor.id, + success: true, + items: capKnowledgeItems(ranked, request.limit ?? 6, request.maxChars ?? 8000), + }; + } + + private async hasKnowledgeArtifacts(context: ArchiverProviderSyncContext): Promise { + const root = await this.findKnowledgeRoot(context.project.rootPath); + if (!root) return false; + try { + const graphPath = join(root, KNOWLEDGE_GRAPH_FILE_NAME); + const graphStat = await stat(graphPath); + return graphStat.isFile() && graphStat.size <= MAX_KNOWLEDGE_FILE_BYTES; + } catch { + return false; + } + } + + private async readKnowledgeDocuments( + context: ArchiverProviderSyncContext + ): Promise> { + const root = await this.findKnowledgeRoot(context.project.rootPath); + if (!root) return []; + const files = await collectKnowledgeFiles(root, MAX_KNOWLEDGE_FILES); + const documents: Array<{ path: string; content: string }> = []; + for (const file of files) { + try { + const fileStat = await stat(file); + if (!fileStat.isFile() || fileStat.size > MAX_KNOWLEDGE_FILE_BYTES) continue; + const content = (await readFile(file, 'utf8')).trim(); + if (!content) continue; + documents.push({ + path: relative(context.project.rootPath, file).replace(/\\/g, '/'), + content, + }); + } catch { + continue; + } + } + return documents; + } + + private async findKnowledgeRoot(projectRoot: string): Promise { + for (const name of KNOWLEDGE_ROOT_NAMES) { + const candidate = join(projectRoot, name); + try { + if ((await stat(candidate)).isDirectory()) return candidate; + } catch { + continue; + } + } + return null; + } +} + +async function collectKnowledgeFiles(root: string, limit: number): Promise { + const files: string[] = []; + const pending = [root]; + while (pending.length > 0 && files.length < limit) { + const current = pending.shift(); + if (!current) continue; + let entries; + try { + entries = await readdir(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (files.length >= limit) break; + if (entry.name === 'node_modules' || entry.name === '.git') continue; + const path = join(current, entry.name); + if (entry.isDirectory()) { + pending.push(path); + } else if (entry.isFile() && KNOWLEDGE_FILE_EXTENSIONS.has(extensionOf(entry.name))) { + files.push(path); + } + } + } + return files.sort(); +} + +function extensionOf(fileName: string): string { + const index = fileName.lastIndexOf('.'); + return index >= 0 ? fileName.slice(index).toLowerCase() : ''; +} + +function formatDocument(path: string, content: string): string { + const normalized = content.replace(/\s+/g, ' ').trim(); + const preview = normalized.length > 2400 ? `${normalized.slice(0, 2400)}…` : normalized; + return `- ${path}: ${preview}`; +} diff --git a/src/advance/archiver/codegraph-client.ts b/src/advance/archiver/codegraph-client.ts new file mode 100644 index 0000000..a77a451 --- /dev/null +++ b/src/advance/archiver/codegraph-client.ts @@ -0,0 +1,166 @@ +import type { Project } from '../types.js'; +import { + ArchiverProviderOrchestrator, + type ArchiverProviderCoordinator, + type ArchiverProviderExecution, +} from './provider-orchestrator.js'; +import { ArchiverProviderRegistry } from './provider-registry.js'; +import { BuiltinProviderAdapter } from './adapters/builtin-provider-adapter.js'; +import type { + ArchiverProviderContextRequest, + ArchiverProviderExecutionStrategy, + ArchiverProviderQueryRequest, + ArchiverProviderRunReport, +} from './provider-types.js'; + +interface CodeGraphFetchResponse { + ok: boolean; + status: number; + json(): Promise; +} + +export type CodeGraphFetch = ( + url: string, + init: { + method: 'POST'; + headers: Record; + body: string; + signal: AbortSignal; + } +) => Promise; + +export interface CodeGraphClientOptions { + baseUrl: string; + fetch?: CodeGraphFetch; + timeoutMs?: number; +} + +interface CodeGraphRpcResponse { + ok?: boolean; + result?: unknown; + error?: unknown; +} + +const DEFAULT_REQUEST_TIMEOUT_MS = 30 * 60 * 1000; +const SERVER_MANAGED_STRATEGY: ArchiverProviderExecutionStrategy = { + schemaVersion: 1, + primary: 'builtin', + fallbacks: [], + enrichers: [], + builtinFallback: true, +}; + +/** 角色子进程使用的 CodeGraph Server 客户端。 */ +export class CodeGraphClient implements ArchiverProviderCoordinator { + private readonly baseUrl: string; + private readonly fetchImpl: CodeGraphFetch; + private readonly timeoutMs: number; + private readonly automaticStrategy: ArchiverProviderExecutionStrategy; + + constructor(options: CodeGraphClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ''); + this.fetchImpl = + options.fetch ?? + ((url, init) => fetch(url, init) as unknown as Promise); + this.timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + // 仅兼容现有协调器签名;Provider 遴选始终由服务端 Registry 决定。 + this.automaticStrategy = SERVER_MANAGED_STRATEGY; + } + + getAutomaticStrategy(): ArchiverProviderExecutionStrategy { + return this.automaticStrategy; + } + + hasProjectKnowledgeSource( + project: Project, + _archiveRoot: string, + _strategy: ArchiverProviderExecutionStrategy = this.automaticStrategy + ): Promise { + return this.call('project.knowledge.available', { projectId: project.id }); + } + + loadProjectKnowledgeContext( + project: Project, + _archiveRoot: string, + _strategy: ArchiverProviderExecutionStrategy = this.automaticStrategy, + request: ArchiverProviderContextRequest = {} + ): Promise { + return this.call('project.knowledge.context', { + projectId: project.id, + request, + }); + } + + queryProjectKnowledge( + project: Project, + _archiveRoot: string, + _strategy: ArchiverProviderExecutionStrategy, + request: ArchiverProviderQueryRequest + ): Promise { + return this.call('project.knowledge.query', { + projectId: project.id, + request, + }); + } + + syncProject( + project: Project, + _archiveRoot: string, + _strategy: ArchiverProviderExecutionStrategy = this.automaticStrategy + ): Promise { + return this.call('project.sync', { projectId: project.id }); + } + + async finalizeBuiltin( + project: Project, + _archiveRoot: string, + report: ArchiverProviderRunReport, + success: boolean, + message: string + ): Promise { + await this.call('project.finalize-builtin', { + projectId: project.id, + report, + success, + message, + }); + } + + private async call(method: string, params: Record): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetchImpl(`${this.baseUrl}/rpc`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ method, params }), + signal: controller.signal, + }); + const payload = (await response.json()) as CodeGraphRpcResponse; + if (!response.ok || payload.ok !== true) { + const detail = typeof payload.error === 'string' ? payload.error : `HTTP ${response.status}`; + throw new Error(`CodeGraph Server 请求失败:${detail}`); + } + return payload.result as T; + } catch (error) { + if (controller.signal.aborted) { + throw new Error(`CodeGraph Server 请求超时:${method}`); + } + throw error; + } finally { + clearTimeout(timer); + } + } +} + +/** 优先连接 Daemon 托管的 Server;无服务地址时仅保留内置安全回退。 */ +export function createArchiverProviderCoordinator( + baseUrl = process.env.CK_CODEGRAPH_SERVER_URL?.trim() +): ArchiverProviderCoordinator { + return baseUrl + ? new CodeGraphClient({ baseUrl }) + : new ArchiverProviderOrchestrator({ + registry: new ArchiverProviderRegistry([new BuiltinProviderAdapter()]), + autoPrepare: false, + }); +} diff --git a/src/advance/archiver/codegraph-service.ts b/src/advance/archiver/codegraph-service.ts new file mode 100644 index 0000000..d0f4c9f --- /dev/null +++ b/src/advance/archiver/codegraph-service.ts @@ -0,0 +1,541 @@ +import http, { + type IncomingMessage, + type Server as HttpServer, + type ServerResponse, +} from 'node:http'; +import { URL } from 'node:url'; +import { logger } from '../../core/logger.js'; +import type { + CodeGraphProviderStatus, + CodeGraphServiceStatus, +} from '../../electron/shared/service-status.js'; +import type { Project } from '../types.js'; +import { getArchiveRoot } from '../types.js'; +import { + ArchiverProviderOrchestrator, + type ArchiverProviderCoordinator, + type ArchiverProviderExecution, +} from './provider-orchestrator.js'; +import type { + ArchiverProviderContextRequest, + ArchiverProviderDescriptor, + ArchiverProviderExecutionStrategy, + ArchiverProviderProbeResult, + ArchiverProviderQueryRequest, + ArchiverProviderRunReport, +} from './provider-types.js'; + +const MAX_REQUEST_BODY_BYTES = 2 * 1024 * 1024; + +export type CodeGraphServiceBackend = Pick< + ArchiverProviderOrchestrator, + | 'listProviders' + | 'getAutomaticStrategy' + | 'prepareProvider' + | 'probeProject' + | 'readStatus' + | 'hasProjectKnowledgeSource' + | 'loadProjectKnowledgeContext' + | 'queryProjectKnowledge' + | 'syncProject' + | 'finalizeBuiltin' +>; + +export interface CodeGraphServiceOptions { + registry: { get(projectId: string): Project | null }; + orchestrator?: CodeGraphServiceBackend; + port?: number; + autoPrepare?: boolean; +} + +export interface CodeGraphServiceController extends ArchiverProviderCoordinator { + start(): Promise; + stop(): Promise; + getStatus(): CodeGraphServiceStatus; + listProviders(): ArchiverProviderDescriptor[]; + probeProject( + project: Project, + archiveRoot: string, + strategy?: ArchiverProviderExecutionStrategy + ): Promise; + readStatus(archiveRoot: string): Promise; +} + +interface RpcRequest { + method?: unknown; + params?: unknown; +} + +/** Daemon 托管的单一 CodeGraph 服务,统一管理 Provider 运行时和项目任务。 */ +export class CodeGraphService implements CodeGraphServiceController { + private readonly registry: CodeGraphServiceOptions['registry']; + private readonly orchestrator: CodeGraphServiceBackend; + private readonly port: number; + private readonly autoPrepare: boolean; + private readonly providerStatuses = new Map(); + private readonly projectTails = new Map>(); + private readonly syncJobs = new Map>(); + private httpServer: HttpServer | null = null; + private state: CodeGraphServiceStatus['state'] = 'idle'; + private url: string | null = null; + private error: string | null = null; + private activeJobs = 0; + private queuedJobs = 0; + private stopping = false; + + constructor(options: CodeGraphServiceOptions) { + this.registry = options.registry; + this.orchestrator = options.orchestrator ?? new ArchiverProviderOrchestrator(); + this.port = options.port ?? 0; + this.autoPrepare = options.autoPrepare ?? true; + this.resetProviderStatuses(); + } + + async start(): Promise { + if (this.httpServer && this.url) return this.url; + + this.stopping = false; + this.state = 'starting'; + this.error = null; + this.resetProviderStatuses(); + + const server = http.createServer((request, response) => { + void this.handleRequest(request, response).catch(error => { + logger.warn({ err: error }, 'CodeGraph Server 请求处理失败'); + if (!response.headersSent) { + this.writeJson(response, 500, { + ok: false, + error: error instanceof Error ? error.message : 'CodeGraph 请求处理失败', + }); + } else if (!response.writableEnded) { + response.end(); + } + }); + }); + this.httpServer = server; + + try { + await new Promise((resolve, reject) => { + const handleError = (error: Error) => reject(error); + server.once('error', handleError); + server.listen(this.port, '127.0.0.1', () => { + server.off('error', handleError); + resolve(); + }); + }); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : this.port; + this.url = `http://127.0.0.1:${port}`; + this.state = 'running'; + server.on('error', error => { + logger.error({ err: error }, 'CodeGraph Server 运行异常'); + this.state = 'error'; + this.error = 'CodeGraph Server 运行异常,请查看 Daemon 日志'; + }); + server.on('close', () => { + if (!this.stopping && this.httpServer === server) { + this.state = 'error'; + this.error = 'CodeGraph Server 已意外停止'; + this.url = null; + } + }); + if (this.autoPrepare) void this.prepareProviders(); + logger.info({ url: this.url }, 'CodeGraph Server 已启动'); + return this.url; + } catch (error) { + this.httpServer = null; + this.url = null; + this.state = 'error'; + this.error = 'CodeGraph Server 启动失败,请查看 Daemon 日志'; + logger.error({ err: error }, 'CodeGraph Server 启动失败'); + throw error; + } + } + + async stop(): Promise { + const server = this.httpServer; + this.stopping = true; + this.httpServer = null; + if (server) { + await new Promise(resolve => { + server.close(() => resolve()); + }); + } + this.url = null; + this.error = null; + this.state = 'idle'; + this.stopping = false; + } + + getStatus(): CodeGraphServiceStatus { + return { + state: this.state, + url: this.url, + error: this.error, + activeJobs: this.activeJobs, + queuedJobs: this.queuedJobs, + providers: this.listProviders().map(descriptor => ({ + ...(this.providerStatuses.get(descriptor.id) ?? this.initialProviderStatus(descriptor)), + })), + }; + } + + listProviders(): ArchiverProviderDescriptor[] { + return this.orchestrator.listProviders(); + } + + getAutomaticStrategy(): ArchiverProviderExecutionStrategy { + return this.orchestrator.getAutomaticStrategy(); + } + + probeProject( + project: Project, + archiveRoot: string, + strategy: ArchiverProviderExecutionStrategy = this.getAutomaticStrategy() + ): Promise { + return this.runProjectRead(project.id, () => + this.orchestrator.probeProject(project, archiveRoot, strategy) + ); + } + + readStatus(archiveRoot: string): Promise { + return this.orchestrator.readStatus(archiveRoot); + } + + hasProjectKnowledgeSource( + project: Project, + archiveRoot: string, + strategy: ArchiverProviderExecutionStrategy = this.getAutomaticStrategy() + ): Promise { + return this.runProjectRead(project.id, () => + this.orchestrator.hasProjectKnowledgeSource(project, archiveRoot, strategy) + ); + } + + loadProjectKnowledgeContext( + project: Project, + archiveRoot: string, + strategy: ArchiverProviderExecutionStrategy = this.getAutomaticStrategy(), + request: ArchiverProviderContextRequest = {} + ): Promise { + return this.runProjectRead(project.id, () => + this.orchestrator.loadProjectKnowledgeContext(project, archiveRoot, strategy, request) + ); + } + + queryProjectKnowledge( + project: Project, + archiveRoot: string, + strategy: ArchiverProviderExecutionStrategy, + request: ArchiverProviderQueryRequest + ): Promise { + return this.runProjectRead(project.id, () => + this.orchestrator.queryProjectKnowledge(project, archiveRoot, strategy, request) + ); + } + + syncProject( + project: Project, + archiveRoot: string, + strategy: ArchiverProviderExecutionStrategy = this.getAutomaticStrategy() + ): Promise { + const existing = this.syncJobs.get(project.id); + if (existing) return existing; + + const job = this.enqueueProjectMutation(project.id, () => + this.orchestrator.syncProject(project, archiveRoot, strategy) + ); + this.syncJobs.set(project.id, job); + void job.then( + () => this.clearSyncJob(project.id, job), + () => this.clearSyncJob(project.id, job) + ); + return job; + } + + finalizeBuiltin( + project: Project, + archiveRoot: string, + report: ArchiverProviderRunReport, + success: boolean, + message: string + ): Promise { + return this.enqueueProjectMutation(project.id, () => + this.orchestrator.finalizeBuiltin(project, archiveRoot, report, success, message) + ); + } + + private clearSyncJob( + projectId: string, + job: Promise + ): void { + if (this.syncJobs.get(projectId) === job) this.syncJobs.delete(projectId); + } + + private resetProviderStatuses(): void { + this.providerStatuses.clear(); + for (const descriptor of this.orchestrator.listProviders()) { + this.providerStatuses.set(descriptor.id, this.initialProviderStatus(descriptor)); + } + } + + private initialProviderStatus( + descriptor: ArchiverProviderDescriptor + ): CodeGraphProviderStatus { + if (descriptor.kind === 'builtin') { + return { + providerId: descriptor.id, + displayName: descriptor.displayName, + state: 'ready', + prepared: true, + version: null, + message: '应用内置能力', + }; + } + if (descriptor.managedRuntime) { + return { + providerId: descriptor.id, + displayName: descriptor.displayName, + state: 'preparable', + prepared: false, + version: descriptor.managedRuntime.version, + message: '等待系统自动准备', + }; + } + return { + providerId: descriptor.id, + displayName: descriptor.displayName, + state: descriptor.automation === 'manual' ? 'manual' : 'unavailable', + prepared: false, + version: null, + message: + descriptor.automation === 'manual' + ? '需要 Agent 工作流调度' + : '未声明可用运行时', + }; + } + + private async prepareProviders(): Promise { + for (const descriptor of this.listProviders()) { + if (this.stopping || descriptor.kind === 'builtin' || !descriptor.managedRuntime) continue; + this.providerStatuses.set(descriptor.id, { + providerId: descriptor.id, + displayName: descriptor.displayName, + state: 'preparing', + prepared: false, + version: descriptor.managedRuntime.version, + message: '正在准备应用私有运行时', + }); + try { + const result = await this.orchestrator.prepareProvider(descriptor.id); + if (this.stopping) return; + this.providerStatuses.set(descriptor.id, { + providerId: descriptor.id, + displayName: descriptor.displayName, + state: result.success + ? result.manual || descriptor.automation === 'manual' + ? 'manual' + : 'ready' + : 'unavailable', + prepared: result.prepared, + version: result.version ?? descriptor.managedRuntime.version, + message: result.success + ? result.manual || descriptor.automation === 'manual' + ? 'Skill 已准备,等待 Agent 工作流调度' + : '托管运行时已就绪' + : '自动准备失败,请查看 Daemon 日志', + }); + if (!result.success) { + logger.warn( + { providerId: descriptor.id, detail: result.message }, + 'CodeGraph Provider 自动准备失败' + ); + } + } catch (error) { + if (this.stopping) return; + this.providerStatuses.set(descriptor.id, { + providerId: descriptor.id, + displayName: descriptor.displayName, + state: 'unavailable', + prepared: false, + version: descriptor.managedRuntime.version, + message: '自动准备失败,请查看 Daemon 日志', + }); + logger.warn({ err: error, providerId: descriptor.id }, 'CodeGraph Provider 自动准备异常'); + } + } + } + + private enqueueProjectMutation(projectId: string, operation: () => Promise): Promise { + const previous = this.projectTails.get(projectId); + if (previous) this.queuedJobs += 1; + const result = (async () => { + if (previous) { + try { + await previous; + } finally { + this.queuedJobs = Math.max(0, this.queuedJobs - 1); + } + } + this.activeJobs += 1; + try { + return await operation(); + } finally { + this.activeJobs = Math.max(0, this.activeJobs - 1); + } + })(); + const tail = result.then( + () => undefined, + () => undefined + ); + this.projectTails.set(projectId, tail); + void tail.then(() => { + if (this.projectTails.get(projectId) === tail) this.projectTails.delete(projectId); + }); + return result; + } + + private async runProjectRead(projectId: string, operation: () => Promise): Promise { + const pending = this.projectTails.get(projectId); + if (pending) { + this.queuedJobs += 1; + try { + await pending; + } finally { + this.queuedJobs = Math.max(0, this.queuedJobs - 1); + } + } + this.activeJobs += 1; + try { + return await operation(); + } finally { + this.activeJobs = Math.max(0, this.activeJobs - 1); + } + } + + private async handleRequest( + request: IncomingMessage, + response: ServerResponse + ): Promise { + const url = new URL(request.url ?? '/', 'http://127.0.0.1'); + if (request.method === 'GET' && url.pathname === '/health') { + this.writeJson(response, 200, { ok: true, result: this.getStatus() }); + return; + } + if (request.method !== 'POST' || url.pathname !== '/rpc') { + this.writeJson(response, 404, { ok: false, error: 'not found' }); + return; + } + + const body = (await this.readJsonBody(request)) as RpcRequest; + if (typeof body.method !== 'string') throw new Error('缺少 CodeGraph RPC method'); + const result = await this.dispatch(body.method, body.params); + this.writeJson(response, 200, { ok: true, result }); + } + + private async dispatch(method: string, rawParams: unknown): Promise { + const params = asRecord(rawParams); + const project = this.getProject(params.projectId); + const archiveRoot = getArchiveRoot(project); + switch (method) { + case 'project.sync': + return this.syncProject(project, archiveRoot); + case 'project.finalize-builtin': { + const report = params.report; + if (!isRunReport(report) || report.projectId !== project.id) { + throw new Error('CodeGraph 编排报告无效'); + } + await this.finalizeBuiltin( + project, + archiveRoot, + report, + params.success === true, + typeof params.message === 'string' ? params.message : '' + ); + return null; + } + case 'project.knowledge.available': + return this.hasProjectKnowledgeSource(project, archiveRoot); + case 'project.knowledge.context': + return this.loadProjectKnowledgeContext( + project, + archiveRoot, + this.getAutomaticStrategy(), + parseContextRequest(params.request) + ); + case 'project.knowledge.query': + return this.queryProjectKnowledge( + project, + archiveRoot, + this.getAutomaticStrategy(), + parseQueryRequest(params.request) + ); + default: + throw new Error(`未知 CodeGraph RPC method: ${method}`); + } + } + + private getProject(value: unknown): Project { + if (typeof value !== 'string' || !value.trim()) throw new Error('缺少项目 ID'); + const project = this.registry.get(value); + if (!project) throw new Error(`项目不存在: ${value}`); + return project; + } + + private async readJsonBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.length; + if (total > MAX_REQUEST_BODY_BYTES) throw new Error('CodeGraph 请求体过大'); + chunks.push(buffer); + } + const raw = Buffer.concat(chunks).toString('utf8'); + return raw ? JSON.parse(raw) : {}; + } + + private writeJson(response: ServerResponse, statusCode: number, payload: unknown): void { + response.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify(payload)); + } +} + +function asRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as Record; +} + +function parseContextRequest(value: unknown): ArchiverProviderContextRequest { + const request = asRecord(value); + return { + ...(typeof request.maxChars === 'number' ? { maxChars: request.maxChars } : {}), + }; +} + +function parseQueryRequest(value: unknown): ArchiverProviderQueryRequest { + const request = asRecord(value); + if (typeof request.query !== 'string' || !request.query.trim()) { + throw new Error('CodeGraph 查询内容不能为空'); + } + return { + query: request.query, + ...(request.role === 'reviewer' || request.role === 'maintainer' || request.role === 'archiver' + ? { role: request.role } + : {}), + ...(typeof request.limit === 'number' ? { limit: request.limit } : {}), + ...(typeof request.maxChars === 'number' ? { maxChars: request.maxChars } : {}), + }; +} + +function isRunReport(value: unknown): value is ArchiverProviderRunReport { + if (!value || typeof value !== 'object') return false; + const report = value as Partial; + return ( + report.schemaVersion === 1 && + typeof report.projectId === 'string' && + typeof report.selectedPrimary === 'string' && + Number.isFinite(report.generatedAt) && + Array.isArray(report.statuses) + ); +} diff --git a/src/advance/archiver/managed-provider-runtime.ts b/src/advance/archiver/managed-provider-runtime.ts new file mode 100644 index 0000000..3742ee0 --- /dev/null +++ b/src/advance/archiver/managed-provider-runtime.ts @@ -0,0 +1,516 @@ +import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { getAppStorageDir } from '../../core/platform.js'; +import type { + ArchiverProviderDescriptor, + ArchiverProviderManagedInvocation, + ArchiverProviderManagedRuntime, + ArchiverProviderPrepareResult, + ArchiverProviderProvisioner, +} from './provider-types.js'; +import type { ProviderCommandRunner, ProviderShellResult } from './provider-shell.js'; + +export interface ManagedProviderRuntimeOptions { + rootDir?: string; + shell: ProviderCommandRunner; + platform?: NodeJS.Platform; +} + +interface RuntimeMarker { + schemaVersion: 1; + providerId: string; + kind: ArchiverProviderManagedRuntime['kind']; + version: string; + preparedAt: number; +} + +interface BootstrapInvocation { + executable: string; + argsPrefix: string[]; +} + +const INSTALL_NETWORK_ENVIRONMENT_KEYS = [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'ALL_PROXY', + 'NO_PROXY', + 'SSL_CERT_FILE', + 'REQUESTS_CA_BUNDLE', + 'NODE_EXTRA_CA_CERTS', +] as const; + +/** 将外部 Provider 安装到应用自有目录,避免污染用户全局运行时。 */ +export class ManagedProviderRuntime implements ArchiverProviderProvisioner { + private readonly rootDir: string; + private readonly shell: ProviderCommandRunner; + private readonly platform: NodeJS.Platform; + private readonly preparations = new Map>(); + + constructor(options: ManagedProviderRuntimeOptions) { + this.rootDir = resolve(options.rootDir ?? join(getAppStorageDir(), 'providers', 'runtime')); + this.shell = options.shell; + this.platform = options.platform ?? process.platform; + } + + async resolve( + descriptor: ArchiverProviderDescriptor + ): Promise { + const specification = descriptor.managedRuntime; + if (!specification) return null; + + const versionRoot = this.getVersionRoot(descriptor, specification); + const marker = await this.readMarker(versionRoot); + if ( + !marker || + marker.providerId !== descriptor.id || + marker.kind !== specification.kind || + marker.version !== specification.version + ) { + return null; + } + + if (specification.kind === 'git-skill') { + const repositoryRoot = join(versionRoot, 'repository'); + const skillPath = resolve(repositoryRoot, specification.skillPath); + if (!isPathInside(repositoryRoot, skillPath) || !(await isFile(skillPath))) return null; + return { + providerId: descriptor.id, + success: true, + prepared: true, + manual: true, + version: specification.version, + message: 'Skill 资源已由系统准备,等待 Agent 工作流调用', + assetRoot: repositoryRoot, + }; + } + + const invocation = await this.resolveCliInvocation(versionRoot, specification); + if (!invocation) return null; + return { + providerId: descriptor.id, + success: true, + prepared: true, + version: specification.version, + message: '托管运行时已准备', + invocation, + assetRoot: versionRoot, + }; + } + + async prepare(descriptor: ArchiverProviderDescriptor): Promise { + const specification = descriptor.managedRuntime; + if (!specification) { + return { + providerId: descriptor.id, + success: false, + prepared: false, + message: 'Provider 未声明托管运行时', + }; + } + + const key = `${descriptor.id}@${specification.version}`; + const existing = this.preparations.get(key); + if (existing) return existing; + + const preparation = this.prepareOnce(descriptor, specification).finally(() => { + this.preparations.delete(key); + }); + this.preparations.set(key, preparation); + return preparation; + } + + private async prepareOnce( + descriptor: ArchiverProviderDescriptor, + specification: ArchiverProviderManagedRuntime + ): Promise { + const resolved = await this.resolve(descriptor); + if (resolved) return resolved; + + const versionRoot = this.getVersionRoot(descriptor, specification); + await mkdir(versionRoot, { recursive: true }); + + switch (specification.kind) { + case 'python-package': + return this.preparePythonPackage(descriptor, specification, versionRoot); + case 'npm-package': + return this.prepareNpmPackage(descriptor, specification, versionRoot); + case 'git-skill': + return this.prepareGitSkill(descriptor, specification, versionRoot); + } + } + + private async preparePythonPackage( + descriptor: ArchiverProviderDescriptor, + specification: Extract, + versionRoot: string + ): Promise { + const bootstrap = await this.findBootstrap( + this.platform === 'win32' + ? [ + { executable: 'python', argsPrefix: [] }, + { executable: 'py', argsPrefix: ['-3'] }, + { executable: 'python3', argsPrefix: [] }, + ] + : [ + { executable: 'python3', argsPrefix: [] }, + { executable: 'python', argsPrefix: [] }, + ], + versionRoot + ); + if (!bootstrap) { + return this.failure(descriptor, '未检测到可用于创建隔离环境的 Python 3'); + } + + const virtualEnvironment = join(versionRoot, 'venv'); + const managedPython = this.getManagedPythonPath(virtualEnvironment); + if (!(await isFile(managedPython))) { + const createResult = await this.shell.run({ + executable: bootstrap.executable, + args: [...bootstrap.argsPrefix, '-m', 'venv', virtualEnvironment], + cwd: versionRoot, + inheritEnv: [...INSTALL_NETWORK_ENVIRONMENT_KEYS], + env: { PYTHONUTF8: '1' }, + timeoutMs: 5 * 60 * 1000, + }); + if (!createResult.success) return this.failure(descriptor, shellFailure(createResult)); + } + + const installResult = await this.shell.run({ + executable: managedPython, + args: [ + '-m', + 'pip', + 'install', + '--disable-pip-version-check', + '--no-input', + '--upgrade', + `${specification.packageName}==${specification.version}`, + ], + cwd: versionRoot, + inheritEnv: [...INSTALL_NETWORK_ENVIRONMENT_KEYS], + env: { + PIP_DISABLE_PIP_VERSION_CHECK: '1', + PIP_NO_INPUT: '1', + PYTHONUTF8: '1', + }, + timeoutMs: 30 * 60 * 1000, + maxOutputBytes: 512 * 1024, + }); + if (!installResult.success) return this.failure(descriptor, shellFailure(installResult)); + + const invocation = await this.resolveCliInvocation(versionRoot, specification); + if (!invocation) return this.failure(descriptor, '安装完成但未找到 Provider 入口'); + const verification = await this.verifyInvocation(invocation, versionRoot); + if (!verification.success) return this.failure(descriptor, shellFailure(verification)); + + await this.writeMarker(versionRoot, descriptor, specification); + return { + providerId: descriptor.id, + success: true, + prepared: true, + version: specification.version, + message: '已在应用隔离环境中自动准备 Provider', + invocation, + assetRoot: versionRoot, + }; + } + + private async prepareNpmPackage( + descriptor: ArchiverProviderDescriptor, + specification: Extract, + versionRoot: string + ): Promise { + const bootstrap = await this.findBootstrap( + [{ executable: 'npm', argsPrefix: [] }], + versionRoot + ); + if (!bootstrap) return this.failure(descriptor, '未检测到 npm 运行时'); + + const installResult = await this.shell.run({ + executable: bootstrap.executable, + args: [ + ...bootstrap.argsPrefix, + 'install', + '--no-audit', + '--no-fund', + '--save-exact', + '--prefix', + versionRoot, + `${specification.packageName}@${specification.version}`, + ], + cwd: versionRoot, + inheritEnv: [...INSTALL_NETWORK_ENVIRONMENT_KEYS], + env: { npm_config_update_notifier: 'false' }, + timeoutMs: 30 * 60 * 1000, + maxOutputBytes: 512 * 1024, + }); + if (!installResult.success) return this.failure(descriptor, shellFailure(installResult)); + + const invocation = await this.resolveCliInvocation(versionRoot, specification); + if (!invocation) return this.failure(descriptor, '安装完成但未找到 Provider 入口'); + const verification = await this.verifyInvocation(invocation, versionRoot, 2 * 60 * 1000); + if (!verification.success) return this.failure(descriptor, shellFailure(verification)); + + await this.writeMarker(versionRoot, descriptor, specification); + return { + providerId: descriptor.id, + success: true, + prepared: true, + version: specification.version, + message: '已在应用隔离环境中自动准备 Provider', + invocation, + assetRoot: versionRoot, + }; + } + + private async prepareGitSkill( + descriptor: ArchiverProviderDescriptor, + specification: Extract, + versionRoot: string + ): Promise { + const bootstrap = await this.findBootstrap( + [{ executable: 'git', argsPrefix: [] }], + versionRoot + ); + if (!bootstrap) return this.failure(descriptor, '未检测到 Git,无法准备 Skill 资源'); + + const repositoryRoot = join(versionRoot, 'repository'); + if (await pathExists(repositoryRoot)) { + this.assertManagedPath(repositoryRoot); + await rm(repositoryRoot, { recursive: true, force: true }); + } + + const cloneResult = await this.shell.run({ + executable: bootstrap.executable, + args: [ + ...bootstrap.argsPrefix, + 'clone', + '--depth', + '1', + '--branch', + specification.revision, + '--single-branch', + specification.repository, + repositoryRoot, + ], + cwd: versionRoot, + inheritEnv: [...INSTALL_NETWORK_ENVIRONMENT_KEYS, 'GIT_SSL_CAINFO'], + env: { GIT_TERMINAL_PROMPT: '0' }, + timeoutMs: 15 * 60 * 1000, + maxOutputBytes: 512 * 1024, + }); + if (!cloneResult.success) return this.failure(descriptor, shellFailure(cloneResult)); + + const skillPath = resolve(repositoryRoot, specification.skillPath); + if (!isPathInside(repositoryRoot, skillPath) || !(await isFile(skillPath))) { + return this.failure(descriptor, 'Skill 仓库中缺少约定入口'); + } + + await this.writeMarker(versionRoot, descriptor, specification); + return { + providerId: descriptor.id, + success: true, + prepared: true, + manual: true, + version: specification.version, + message: 'Skill 已自动准备;执行阶段需要 Agent 工作流调度', + assetRoot: repositoryRoot, + }; + } + + private async findBootstrap( + candidates: BootstrapInvocation[], + cwd: string + ): Promise { + for (const candidate of candidates) { + const result = await this.shell.run({ + executable: candidate.executable, + args: [...candidate.argsPrefix, '--version'], + cwd, + inheritEnv: [...INSTALL_NETWORK_ENVIRONMENT_KEYS], + timeoutMs: 15_000, + maxOutputBytes: 16 * 1024, + }); + if (result.success) return candidate; + } + return null; + } + + private async verifyInvocation( + invocation: ArchiverProviderManagedInvocation, + cwd: string, + timeoutMs = 30_000 + ): Promise { + return this.shell.run({ + executable: invocation.executable, + args: [...invocation.argsPrefix, '--version'], + cwd, + inheritEnv: invocation.inheritEnv, + env: invocation.env, + timeoutMs, + maxOutputBytes: 32 * 1024, + }); + } + + private async resolveCliInvocation( + versionRoot: string, + specification: Exclude + ): Promise { + const executable = + specification.kind === 'python-package' + ? await firstExistingFile( + this.getPythonEntrypointCandidates(versionRoot, specification.entrypoint) + ) + : await firstExistingFile( + this.getNpmEntrypointCandidates(versionRoot, specification.entrypoint) + ); + if (!executable) return null; + return { + executable, + argsPrefix: [], + inheritEnv: [...INSTALL_NETWORK_ENVIRONMENT_KEYS], + version: specification.version, + }; + } + + private getPythonEntrypointCandidates(versionRoot: string, entrypoint: string): string[] { + const binRoot = join(versionRoot, 'venv', this.platform === 'win32' ? 'Scripts' : 'bin'); + return this.platform === 'win32' + ? [join(binRoot, `${entrypoint}.exe`), join(binRoot, `${entrypoint}.cmd`)] + : [join(binRoot, entrypoint)]; + } + + private getNpmEntrypointCandidates(versionRoot: string, entrypoint: string): string[] { + const binRoot = join(versionRoot, 'node_modules', '.bin'); + return this.platform === 'win32' + ? [join(binRoot, `${entrypoint}.cmd`), join(binRoot, `${entrypoint}.exe`)] + : [join(binRoot, entrypoint)]; + } + + private getManagedPythonPath(virtualEnvironment: string): string { + return join( + virtualEnvironment, + this.platform === 'win32' ? 'Scripts' : 'bin', + this.platform === 'win32' ? 'python.exe' : 'python' + ); + } + + private getVersionRoot( + descriptor: ArchiverProviderDescriptor, + specification: ArchiverProviderManagedRuntime + ): string { + return join( + this.rootDir, + safePathSegment(descriptor.id), + safePathSegment(specification.version) + ); + } + + private async readMarker(versionRoot: string): Promise { + try { + const parsed = JSON.parse(await readFile(join(versionRoot, 'ready.json'), 'utf8')) as unknown; + if (!parsed || typeof parsed !== 'object') return null; + const marker = parsed as Partial; + return marker.schemaVersion === 1 && + typeof marker.providerId === 'string' && + typeof marker.kind === 'string' && + typeof marker.version === 'string' && + Number.isFinite(marker.preparedAt) + ? (marker as RuntimeMarker) + : null; + } catch { + return null; + } + } + + private async writeMarker( + versionRoot: string, + descriptor: ArchiverProviderDescriptor, + specification: ArchiverProviderManagedRuntime + ): Promise { + const markerPath = join(versionRoot, 'ready.json'); + const temporaryPath = join(versionRoot, `ready.${process.pid}.${Date.now()}.tmp`); + const marker: RuntimeMarker = { + schemaVersion: 1, + providerId: descriptor.id, + kind: specification.kind, + version: specification.version, + preparedAt: Date.now(), + }; + await writeFile(temporaryPath, `${JSON.stringify(marker, null, 2)}\n`, 'utf8'); + await rm(markerPath, { force: true }); + await rename(temporaryPath, markerPath); + } + + private assertManagedPath(target: string): void { + if (!isPathInside(this.rootDir, target)) { + throw new Error('Provider 托管目录越界'); + } + } + + private failure( + descriptor: ArchiverProviderDescriptor, + message: string + ): ArchiverProviderPrepareResult { + return { + providerId: descriptor.id, + success: false, + prepared: false, + message, + }; + } +} + +function safePathSegment(value: string): string { + return value.replace(/[^A-Za-z0-9._-]/g, '_'); +} + +function isPathInside(root: string, target: string): boolean { + const normalizedRoot = resolve(root); + const normalizedTarget = resolve(target); + const pathFromRoot = relative(normalizedRoot, normalizedTarget); + return pathFromRoot === '' || (!pathFromRoot.startsWith('..') && !isAbsolute(pathFromRoot)); +} + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } +} + +async function firstExistingFile(paths: string[]): Promise { + for (const path of paths) { + if (await isFile(path)) return path; + } + return null; +} + +function shellFailure(result: ProviderShellResult): string { + return ( + result.error ?? + firstNonEmptyLine(result.stderr, result.stdout) ?? + `Provider 命令失败,退出码 ${result.exitCode ?? 'null'}` + ); +} + +function firstNonEmptyLine(...values: string[]): string | undefined { + for (const value of values) { + const line = value + .split(/\r?\n/) + .map(item => item.trim()) + .find(Boolean); + if (line) return line; + } + return undefined; +} diff --git a/src/advance/archiver/project-knowledge-source.ts b/src/advance/archiver/project-knowledge-source.ts new file mode 100644 index 0000000..2107b42 --- /dev/null +++ b/src/advance/archiver/project-knowledge-source.ts @@ -0,0 +1,75 @@ +import type { Role, Project } from '../types.js'; +import { getArchiveRoot } from '../types.js'; +import type { ArchiverProviderCoordinator } from './provider-orchestrator.js'; +import { createArchiverProviderCoordinator } from './codegraph-client.js'; + +export interface ProjectKnowledgeQueryOptions { + role?: Role; + limit?: number; + maxChars?: number; +} + +/** Reviewer、Maintainer 依赖的项目知识最小接口。 */ +export interface ProjectKnowledgeSource { + isAvailable(): Promise; + loadContext(maxChars?: number): Promise; + query(query: string, options?: ProjectKnowledgeQueryOptions): Promise; +} + +export interface ArchiverProjectKnowledgeSourceOptions { + project: Project; + orchestrator?: ArchiverProviderCoordinator; +} + +/** 将 Archiver Provider 组合适配为角色无关的项目知识源。 */ +export class ArchiverProjectKnowledgeSource implements ProjectKnowledgeSource { + private readonly project: Project; + private readonly archiveRoot: string; + private readonly orchestrator: ArchiverProviderCoordinator; + + constructor(options: ArchiverProjectKnowledgeSourceOptions) { + this.project = options.project; + this.archiveRoot = getArchiveRoot(options.project); + this.orchestrator = options.orchestrator ?? createArchiverProviderCoordinator(); + } + + isAvailable(): Promise { + return this.orchestrator.hasProjectKnowledgeSource( + this.project, + this.archiveRoot, + this.orchestrator.getAutomaticStrategy() + ); + } + + loadContext(maxChars = 8000): Promise { + return this.orchestrator.loadProjectKnowledgeContext( + this.project, + this.archiveRoot, + this.orchestrator.getAutomaticStrategy(), + { maxChars } + ); + } + + query(query: string, options: ProjectKnowledgeQueryOptions = {}): Promise { + return this.orchestrator.queryProjectKnowledge( + this.project, + this.archiveRoot, + this.orchestrator.getAutomaticStrategy(), + { + query, + role: options.role, + limit: options.limit, + maxChars: options.maxChars, + } + ); + } +} + +/** 合并旧 context.md 与 Provider 上下文,并保留清晰来源边界。 */ +export function mergeProjectKnowledgeContext( + legacyContext: string, + providerContext: string +): string { + const sections = [legacyContext.trim(), providerContext.trim()].filter(Boolean); + return sections.join('\n\n'); +} diff --git a/src/advance/archiver/provider-config.ts b/src/advance/archiver/provider-config.ts new file mode 100644 index 0000000..fbab633 --- /dev/null +++ b/src/advance/archiver/provider-config.ts @@ -0,0 +1,165 @@ +import type { + ArchiverConfig, + ArchiverProviderStrategy, + ArchiverSynthesisPolicy, +} from '../types.js'; +import type { ArchiverProviderExecutionStrategy } from './provider-types.js'; + +export const BUILTIN_ARCHIVER_PROVIDER_ID = 'builtin'; +export const DEFAULT_ARCHIVER_PRIMARY_PROVIDER_ID = 'graphify'; +export const DEFAULT_ARCHIVER_FALLBACK_PROVIDER_ID = 'codebase-memory-mcp'; +export const DEFAULT_ARCHIVER_NAME = 'CodeKeeper Archiver'; +export const DEFAULT_ARCHIVER_CRON = '0 2 * * *'; +export const ARCHIVER_CONFIG_SCHEMA_VERSION = 3; + +/** + * 创建系统默认 Provider 组合。 + * + * 该策略只供内部编排器和旧配置迁移使用,不属于项目配置,也不由用户编辑。 + */ +export function createDefaultArchiverProviderStrategy(): ArchiverProviderStrategy { + return { + sources: [DEFAULT_ARCHIVER_PRIMARY_PROVIDER_ID, DEFAULT_ARCHIVER_FALLBACK_PROVIDER_ID], + augmenters: [], + synthesis: { + provider: BUILTIN_ARCHIVER_PROVIDER_ID, + policy: 'always', + }, + }; +} + +/** 创建不含 Provider 配置的 Archiver 默认配置。 */ +export function createDefaultArchiverConfig(): ArchiverConfig { + return { + role: 'archiver', + schemaVersion: ARCHIVER_CONFIG_SCHEMA_VERSION, + archiverName: DEFAULT_ARCHIVER_NAME, + automation: { + enabled: false, + cron: DEFAULT_ARCHIVER_CRON, + }, + }; +} + +/** 对 Provider ID 列表去空、去重并保持原顺序。 */ +function normalizeProviderIds(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const result: string[] = []; + for (const item of value) { + if (typeof item !== 'string') continue; + const id = item.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + result.push(id); + } + return result; +} + +/** 规范化旧 Provider 组合,仅用于兼容旧测试和旧运行态数据。 */ +export function normalizeArchiverProviderStrategy(value: unknown): ArchiverProviderStrategy { + const defaults = createDefaultArchiverProviderStrategy(); + if (!value || typeof value !== 'object') return defaults; + + const raw = value as Record; + const synthesisRaw = + raw.synthesis && typeof raw.synthesis === 'object' + ? (raw.synthesis as Record) + : undefined; + const synthesisProvider = normalizeText( + synthesisRaw?.provider, + defaults.synthesis.provider + ); + const sources = normalizeProviderIds(raw.sources).filter(id => id !== synthesisProvider); + const sourceSet = new Set(sources); + const augmenters = normalizeProviderIds(raw.augmenters).filter( + id => id !== synthesisProvider && !sourceSet.has(id) + ); + + return { + sources: Array.isArray(raw.sources) ? sources : [...defaults.sources], + augmenters: Array.isArray(raw.augmenters) ? augmenters : [...defaults.augmenters], + synthesis: { + provider: synthesisProvider, + policy: normalizeSynthesisPolicy(synthesisRaw?.policy, defaults.synthesis.policy), + }, + }; +} + +/** + * 将旧的静态组合转换为编排器内部协议。 + * + * 新代码应优先让 Provider Registry 生成自动策略;此函数保留给旧调用方和测试。 + */ +export function toArchiverProviderExecutionStrategy( + value: unknown = createDefaultArchiverProviderStrategy() +): ArchiverProviderExecutionStrategy { + const knowledge = normalizeArchiverProviderStrategy(value); + const synthesisProvider = knowledge.synthesis.provider; + const sources = knowledge.sources.filter(id => id !== synthesisProvider); + const primary = sources[0] ?? (knowledge.synthesis.policy === 'always' ? synthesisProvider : ''); + const enrichers = [...knowledge.augmenters]; + if ( + knowledge.synthesis.policy === 'always' && + sources.length > 0 && + !enrichers.includes(synthesisProvider) + ) { + enrichers.push(synthesisProvider); + } + + return { + schemaVersion: 1, + primary, + fallbacks: sources.slice(1), + enrichers, + builtinFallback: + synthesisProvider === BUILTIN_ARCHIVER_PROVIDER_ID && + knowledge.synthesis.policy !== 'off', + overrides: {}, + }; +} + +/** + * 规范化数据库或 IPC 中的 Archiver 配置。 + * + * V1/V2 的 Provider 字段只用于识别旧格式,不会被带入新的持久化结果。 + */ +export function normalizeArchiverConfig(value: unknown): ArchiverConfig { + const defaults = createDefaultArchiverConfig(); + if (!value || typeof value !== 'object') return defaults; + + const raw = value as Record; + const automation = + raw.automation && typeof raw.automation === 'object' + ? (raw.automation as Record) + : {}; + const legacyEnabled = typeof raw.enabled === 'boolean' ? raw.enabled : undefined; + const legacyCron = typeof raw.reviewSchedule === 'string' ? raw.reviewSchedule : undefined; + + return { + role: 'archiver', + schemaVersion: ARCHIVER_CONFIG_SCHEMA_VERSION, + archiverName: normalizeText(raw.archiverName, defaults.archiverName), + automation: { + enabled: + typeof automation.enabled === 'boolean' + ? automation.enabled + : legacyEnabled ?? defaults.automation.enabled, + cron: normalizeText( + automation.cron, + normalizeText(legacyCron, defaults.automation.cron) + ), + }, + }; +} + +function normalizeSynthesisPolicy( + value: unknown, + fallback: ArchiverSynthesisPolicy +): ArchiverSynthesisPolicy { + return value === 'off' || value === 'fallback' || value === 'always' ? value : fallback; +} + +function normalizeText(value: unknown, fallback: string): string { + return typeof value === 'string' && value.trim() ? value.trim() : fallback; +} diff --git a/src/advance/archiver/provider-launch.ts b/src/advance/archiver/provider-launch.ts new file mode 100644 index 0000000..16fe77e --- /dev/null +++ b/src/advance/archiver/provider-launch.ts @@ -0,0 +1,25 @@ +/** uv/uvx 启动器自动继承的非敏感运行环境。 */ +export const UV_LAUNCH_ENVIRONMENT_KEYS = [ + 'UV_CACHE_DIR', + 'UV_NATIVE_TLS', + 'UV_NO_CONFIG', + 'UV_OFFLINE', + 'UV_PYTHON', + 'UV_PYTHON_INSTALL_DIR', + 'UV_TOOL_BIN_DIR', + 'UV_TOOL_DIR', + 'NO_PROXY', + 'SSL_CERT_FILE', + 'REQUESTS_CA_BUNDLE', +] as const; + +/** Python 模块启动器可显式继承的解释器和虚拟环境变量 */ +export const PYTHON_LAUNCH_ENVIRONMENT_KEYS = [ + 'CONDA_DEFAULT_ENV', + 'CONDA_PREFIX', + 'PYTHONHOME', + 'PYTHONIOENCODING', + 'PYTHONPATH', + 'PYTHONUTF8', + 'VIRTUAL_ENV', +] as const; diff --git a/src/advance/archiver/provider-orchestrator.ts b/src/advance/archiver/provider-orchestrator.ts new file mode 100644 index 0000000..dbe2f7f --- /dev/null +++ b/src/advance/archiver/provider-orchestrator.ts @@ -0,0 +1,616 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { logger } from '../../core/logger.js'; +import type { Project } from '../types.js'; +import { BUILTIN_ARCHIVER_PROVIDER_ID } from './provider-config.js'; +import { + createDefaultArchiverProviderRegistry, + ArchiverProviderRegistry, +} from './provider-registry.js'; +import { ProviderShell, type ProviderCommandRunner } from './provider-shell.js'; +import { ManagedProviderRuntime } from './managed-provider-runtime.js'; +import type { + ArchiverProviderAdapter, + ArchiverProviderContextRequest, + ArchiverProviderDescriptor, + ArchiverProviderExecutionStrategy, + ArchiverProviderOverride, + ArchiverProviderPlacement, + ArchiverProviderPrepareResult, + ArchiverProviderProvisioner, + ArchiverProviderProbeResult, + ArchiverProviderQueryRequest, + ArchiverProviderRunReport, + ArchiverProviderRunStatus, + ArchiverProviderSyncContext, +} from './provider-types.js'; +import { capKnowledgeItems } from './provider-query-utils.js'; + +export interface ArchiverProviderOrchestratorOptions { + registry?: ArchiverProviderRegistry; + shell?: ProviderCommandRunner; + provisioner?: ArchiverProviderProvisioner; + providerRuntimeRoot?: string; + autoPrepare?: boolean; +} + +export interface ArchiverProviderExecution { + report: ArchiverProviderRunReport; + shouldRunBuiltin: boolean; + builtinRequired: boolean; +} + +/** 角色进程与 Daemon 之间共享的 CodeGraph 语义契约。 */ +export interface ArchiverProviderCoordinator { + getAutomaticStrategy(): ArchiverProviderExecutionStrategy; + hasProjectKnowledgeSource( + project: Project, + archiveRoot: string, + strategy?: ArchiverProviderExecutionStrategy + ): Promise; + loadProjectKnowledgeContext( + project: Project, + archiveRoot: string, + strategy?: ArchiverProviderExecutionStrategy, + request?: ArchiverProviderContextRequest + ): Promise; + queryProjectKnowledge( + project: Project, + archiveRoot: string, + strategy: ArchiverProviderExecutionStrategy, + request: ArchiverProviderQueryRequest + ): Promise; + syncProject( + project: Project, + archiveRoot: string, + strategy?: ArchiverProviderExecutionStrategy + ): Promise; + finalizeBuiltin( + project: Project, + archiveRoot: string, + report: ArchiverProviderRunReport, + success: boolean, + message: string + ): Promise; +} + +/** 统一编排 Provider 的选择、回退和增强阶段,不直接理解各 Provider 的 CLI */ +export class ArchiverProviderOrchestrator implements ArchiverProviderCoordinator { + private readonly registry: ArchiverProviderRegistry; + private readonly shell: ProviderCommandRunner; + private readonly provisioner: ArchiverProviderProvisioner; + private readonly autoPrepare: boolean; + + constructor(options: ArchiverProviderOrchestratorOptions = {}) { + this.registry = options.registry ?? createDefaultArchiverProviderRegistry(); + this.shell = options.shell ?? new ProviderShell(); + this.provisioner = + options.provisioner ?? + new ManagedProviderRuntime({ + rootDir: options.providerRuntimeRoot, + shell: this.shell, + }); + this.autoPrepare = options.autoPrepare ?? true; + } + + listProviders(): ArchiverProviderDescriptor[] { + return this.registry.listDescriptors(); + } + + /** 返回当前 Registry 计算出的系统策略,不写入项目配置。 */ + getAutomaticStrategy(): ArchiverProviderExecutionStrategy { + return this.registry.createAutomaticStrategy(); + } + + /** 由 CodeGraph Server 在后台准备单个 Provider 的应用私有运行时。 */ + async prepareProvider(providerId: string): Promise { + const adapter = this.registry.get(providerId); + if (!adapter) { + return { + providerId, + success: false, + prepared: false, + message: '未注册对应的 Provider Adapter', + }; + } + if (adapter.descriptor.kind === 'builtin') { + return { + providerId, + success: true, + prepared: true, + message: '内置 Provider 已就绪', + }; + } + if (!adapter.descriptor.managedRuntime) { + return { + providerId, + success: false, + prepared: false, + manual: adapter.descriptor.automation === 'manual', + message: 'Provider 未声明可自动准备的托管运行时', + }; + } + return this.provisioner.prepare(adapter.descriptor); + } + + async probeProject( + project: Project, + archiveRoot: string, + strategy: ArchiverProviderExecutionStrategy = this.getAutomaticStrategy() + ): Promise { + const providerDataRoot = join(archiveRoot, 'providers'); + await mkdir(providerDataRoot, { recursive: true }); + const context: ArchiverProviderSyncContext = { project, archiveRoot, providerDataRoot }; + const providerIds = uniqueIds([ + ...this.registry.listDescriptors().map(descriptor => descriptor.id), + strategy.primary, + ...strategy.fallbacks, + ...strategy.enrichers, + ]); + + const results: ArchiverProviderProbeResult[] = []; + for (const providerId of providerIds) { + const adapter = this.registry.get(providerId); + if (!adapter) { + results.push({ + providerId, + available: false, + readiness: 'unavailable', + message: '未注册对应的 Provider Adapter', + }); + continue; + } + try { + results.push( + await this.probeWithPreparation(context, adapter, strategy.overrides?.[providerId]) + ); + } catch (error) { + results.push({ + providerId, + available: false, + readiness: 'unavailable', + message: error instanceof Error ? error.message : String(error), + }); + } + } + return results; + } + + async readStatus(archiveRoot: string): Promise { + try { + const raw = await readFile(join(archiveRoot, 'providers', 'status.json'), 'utf8'); + const parsed = JSON.parse(raw) as unknown; + return isArchiverProviderRunReport(parsed) ? parsed : null; + } catch { + return null; + } + } + + async hasProjectKnowledgeSource( + project: Project, + archiveRoot: string, + strategy: ArchiverProviderExecutionStrategy = this.getAutomaticStrategy() + ): Promise { + const entries = await this.getKnowledgeAdapters(project, archiveRoot, strategy); + return entries.some(({ adapter }) => typeof adapter.query === 'function'); + } + + async loadProjectKnowledgeContext( + project: Project, + archiveRoot: string, + strategy: ArchiverProviderExecutionStrategy = this.getAutomaticStrategy(), + request: ArchiverProviderContextRequest = {} + ): Promise { + const context = this.createSyncContext(project, archiveRoot); + const entries = await this.getKnowledgeAdapters(project, archiveRoot, strategy); + const sections = await Promise.all( + entries.map(async ({ adapter, override }) => { + if (!adapter.loadContext) return ''; + try { + const result = await adapter.loadContext( + context, + this.createRuntime(), + request, + override + ); + const content = result.success ? result.content?.trim() : ''; + return content ? `### ${adapter.descriptor.displayName}\n${content}` : ''; + } catch (error) { + logger.warn( + { + err: error instanceof Error ? error.message : String(error), + providerId: adapter.descriptor.id, + projectId: project.id, + }, + 'Archiver Provider 上下文加载失败' + ); + return ''; + } + }) + ); + const maxChars = request.maxChars ?? 8000; + return capKnowledgeItems(sections, sections.length || 1, maxChars).join('\n\n'); + } + + async queryProjectKnowledge( + project: Project, + archiveRoot: string, + strategy: ArchiverProviderExecutionStrategy = this.getAutomaticStrategy(), + request: ArchiverProviderQueryRequest + ): Promise { + if (!request.query.trim()) return []; + const context = this.createSyncContext(project, archiveRoot); + const entries = await this.getKnowledgeAdapters(project, archiveRoot, strategy); + const results = await Promise.all( + entries.map(async ({ adapter, override }) => { + if (!adapter.query) return []; + try { + const result = await adapter.query(context, this.createRuntime(), request, override); + if (!result.success) return []; + return result.items.map(item => `[${adapter.descriptor.displayName}] ${item}`); + } catch (error) { + logger.warn( + { + err: error instanceof Error ? error.message : String(error), + providerId: adapter.descriptor.id, + projectId: project.id, + }, + 'Archiver Provider 查询失败' + ); + return []; + } + }) + ); + return capKnowledgeItems(results.flat(), request.limit ?? 8, request.maxChars ?? 12000); + } + + async syncProject( + project: Project, + archiveRoot: string, + strategy: ArchiverProviderExecutionStrategy = this.getAutomaticStrategy() + ): Promise { + const providerDataRoot = join(archiveRoot, 'providers'); + await mkdir(providerDataRoot, { recursive: true }); + const context: ArchiverProviderSyncContext = { project, archiveRoot, providerDataRoot }; + const report: ArchiverProviderRunReport = { + schemaVersion: 1, + projectId: project.id, + generatedAt: Date.now(), + selectedPrimary: '', + statuses: [], + }; + let shouldRunBuiltin = false; + let builtinRequired = false; + + const candidates = uniqueIds([strategy.primary, ...strategy.fallbacks]); + for (let index = 0; index < candidates.length; index += 1) { + const providerId = candidates[index]; + const placement: ArchiverProviderPlacement = index === 0 ? 'primary' : 'fallback'; + if (providerId === BUILTIN_ARCHIVER_PROVIDER_ID) { + report.selectedPrimary = providerId; + shouldRunBuiltin = true; + builtinRequired = true; + report.statuses.push(this.deferredBuiltinStatus(placement)); + break; + } + + const status = await this.runProvider(context, providerId, placement, strategy); + report.statuses.push(status); + if (status.state === 'completed') { + report.selectedPrimary = providerId; + break; + } + } + + if (!report.selectedPrimary && strategy.builtinFallback) { + report.selectedPrimary = BUILTIN_ARCHIVER_PROVIDER_ID; + shouldRunBuiltin = true; + builtinRequired = true; + report.statuses.push(this.deferredBuiltinStatus('fallback')); + } + + if (report.selectedPrimary) { + for (const providerId of uniqueIds(strategy.enrichers)) { + if (providerId === report.selectedPrimary) continue; + if (providerId === BUILTIN_ARCHIVER_PROVIDER_ID) { + shouldRunBuiltin = true; + report.statuses.push(this.deferredBuiltinStatus('enricher')); + continue; + } + report.statuses.push(await this.runProvider(context, providerId, 'enricher', strategy)); + } + } + + await this.writeReport(context, report); + return { report, shouldRunBuiltin, builtinRequired }; + } + + async finalizeBuiltin( + project: Project, + archiveRoot: string, + report: ArchiverProviderRunReport, + success: boolean, + message: string + ): Promise { + const now = Date.now(); + for (const status of report.statuses) { + if (status.providerId !== BUILTIN_ARCHIVER_PROVIDER_ID || status.state !== 'deferred') + continue; + status.state = success ? 'completed' : 'failed'; + status.finishedAt = now; + status.message = message; + } + report.generatedAt = now; + await this.writeReport( + { + project, + archiveRoot, + providerDataRoot: join(archiveRoot, 'providers'), + }, + report + ); + } + + private async runProvider( + context: ArchiverProviderSyncContext, + providerId: string, + placement: ArchiverProviderPlacement, + strategy: ArchiverProviderExecutionStrategy + ): Promise { + const startedAt = Date.now(); + const adapter = this.registry.get(providerId); + if (!adapter) { + return { + providerId, + placement, + state: 'unavailable', + startedAt, + finishedAt: Date.now(), + message: '未注册对应的 Provider Adapter', + }; + } + if (!adapter.descriptor.placements.includes(placement)) { + return { + providerId, + placement, + state: 'skipped', + startedAt, + finishedAt: Date.now(), + message: `Provider 不支持 ${placement} 位置`, + }; + } + const override = strategy.overrides?.[providerId]; + try { + const probe = await this.probeWithPreparation(context, adapter, override); + if (adapter.descriptor.automation === 'manual') { + const readyForAgent = Boolean(probe.prepared || probe.available); + return { + providerId, + placement, + state: readyForAgent ? 'deferred' : 'unavailable', + startedAt, + finishedAt: Date.now(), + version: probe.version, + message: readyForAgent + ? 'Skill 已自动准备,等待 Agent 工作流执行' + : (probe.message ?? '该 Provider 需要 Agent 工作流执行'), + }; + } + if (!probe.available) { + return { + providerId, + placement, + state: 'unavailable', + startedAt, + finishedAt: Date.now(), + message: probe.message, + }; + } + + const result = await adapter.sync(context, this.createRuntime(), override); + return { + providerId, + placement, + state: result.success ? (result.skipped ? 'skipped' : 'completed') : 'failed', + startedAt, + finishedAt: Date.now(), + version: probe.version, + message: result.message, + artifacts: result.artifacts, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn( + { err: message, providerId, projectId: context.project.id }, + 'Archiver Provider 执行失败' + ); + return { + providerId, + placement, + state: 'failed', + startedAt, + finishedAt: Date.now(), + message, + }; + } + } + + private createSyncContext(project: Project, archiveRoot: string): ArchiverProviderSyncContext { + return { + project, + archiveRoot, + providerDataRoot: join(archiveRoot, 'providers'), + }; + } + + private async getKnowledgeAdapters( + project: Project, + archiveRoot: string, + strategy: ArchiverProviderExecutionStrategy + ): Promise< + Array<{ + adapter: ArchiverProviderAdapter; + override: ArchiverProviderOverride | undefined; + }> + > { + const report = await this.readStatus(archiveRoot); + if (report && report.projectId !== project.id) return []; + const completedIds = new Set( + (report?.statuses ?? []) + .filter(status => status.state === 'completed') + .map(status => status.providerId) + ); + const providerIds = report + ? uniqueIds([report.selectedPrimary, ...report.statuses.map(status => status.providerId)]) + : uniqueIds([strategy.primary, ...strategy.fallbacks, ...strategy.enrichers]); + const passiveProviderIds = this.registry + .listDescriptors() + .filter( + descriptor => descriptor.automation === 'manual' && descriptor.capabilities.includes('query') + ) + .map(descriptor => descriptor.id); + const knowledgeProviderIds = uniqueIds([...providerIds, ...passiveProviderIds]); + const context = this.createSyncContext(project, archiveRoot); + const entries = await Promise.all( + knowledgeProviderIds.map(async providerId => { + const adapter = this.registry.get(providerId); + if (!adapter) return null; + // 尚无编排状态时仅允许内置源和被动 Skill 产物,避免角色查询启动外部 CLI。 + if ( + !report && + adapter.descriptor.kind !== 'builtin' && + adapter.descriptor.automation !== 'manual' + ) { + return null; + } + const override = strategy.overrides?.[providerId]; + const completed = completedIds.has(providerId); + if (completed) return { adapter, override }; + if (!adapter.isReady) return null; + try { + return (await adapter.isReady(context, this.createRuntime(), override)) + ? { adapter, override } + : null; + } catch { + return null; + } + }) + ); + return entries.filter( + ( + entry + ): entry is { + adapter: ArchiverProviderAdapter; + override: ArchiverProviderOverride | undefined; + } => entry !== null + ); + } + + private deferredBuiltinStatus(placement: ArchiverProviderPlacement): ArchiverProviderRunStatus { + const now = Date.now(); + return { + providerId: BUILTIN_ARCHIVER_PROVIDER_ID, + placement, + state: 'deferred', + startedAt: now, + finishedAt: now, + message: '等待 Archiver 内置知识提炼阶段执行', + }; + } + + private createRuntime() { + return { shell: this.shell, provisioner: this.provisioner }; + } + + private async probeWithPreparation( + context: ArchiverProviderSyncContext, + adapter: ArchiverProviderAdapter, + override: ArchiverProviderOverride | undefined + ): Promise { + let probe = await adapter.probe(context, this.createRuntime(), override); + if ( + probe.available || + !this.autoPrepare || + !adapter.prepare || + !adapter.descriptor.managedRuntime + ) { + return probe; + } + + const preparation = await adapter.prepare(context, this.createRuntime(), override); + if (!preparation.success) { + return { + ...probe, + readiness: adapter.descriptor.automation === 'manual' ? 'manual' : 'unavailable', + prepared: false, + version: preparation.version ?? probe.version, + message: preparation.message ?? probe.message, + }; + } + + probe = await adapter.probe(context, this.createRuntime(), override); + return { + ...probe, + prepared: preparation.prepared || probe.prepared, + version: probe.version ?? preparation.version, + message: probe.message ?? preparation.message, + }; + } + + private async writeReport( + context: ArchiverProviderSyncContext, + report: ArchiverProviderRunReport + ): Promise { + await mkdir(context.providerDataRoot, { recursive: true }); + await writeFile( + join(context.providerDataRoot, 'status.json'), + `${JSON.stringify(report, null, 2)}\n`, + 'utf8' + ); + } +} + +function uniqueIds(ids: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const id of ids) { + const normalized = id.trim(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +function isArchiverProviderRunReport(value: unknown): value is ArchiverProviderRunReport { + if (!value || typeof value !== 'object') return false; + const report = value as Partial; + if ( + report.schemaVersion !== 1 || + typeof report.projectId !== 'string' || + !Number.isFinite(report.generatedAt) || + typeof report.selectedPrimary !== 'string' || + !Array.isArray(report.statuses) + ) { + return false; + } + const placements = new Set(['primary', 'fallback', 'enricher']); + const states = new Set([ + 'selected', + 'completed', + 'unavailable', + 'failed', + 'skipped', + 'deferred', + ]); + return report.statuses.every(status => { + if (!status || typeof status !== 'object') return false; + return ( + typeof status.providerId === 'string' && + placements.has(status.placement) && + states.has(status.state) && + Number.isFinite(status.startedAt) && + Number.isFinite(status.finishedAt) + ); + }); +} diff --git a/src/advance/archiver/provider-query-utils.ts b/src/advance/archiver/provider-query-utils.ts new file mode 100644 index 0000000..779a863 --- /dev/null +++ b/src/advance/archiver/provider-query-utils.ts @@ -0,0 +1,134 @@ +const DEFAULT_ITEM_LIMIT = 6; +const DEFAULT_TOTAL_CHARS = 8000; + +/** 解析 Provider 的 JSON 输出,兼容 Markdown 代码块包装。 */ +export function parseProviderJson(output: string): unknown { + const trimmed = output.trim(); + if (!trimmed) return null; + const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed); + return JSON.parse(fenced?.[1] ?? trimmed) as unknown; +} + +/** 将常见查询结果 JSON 投影为适合提示词消费的短文本。 */ +export function formatProviderJsonItems( + value: unknown, + limit = DEFAULT_ITEM_LIMIT, + maxChars = DEFAULT_TOTAL_CHARS +): string[] { + const candidates = findCandidateItems(value); + const items = candidates.map(formatCandidate).filter(Boolean); + if (items.length === 0 && value && typeof value === 'object') { + items.push(formatCandidate(value)); + } + return capKnowledgeItems(items, limit, maxChars); +} + +/** 对知识条目去空、去重并施加总字符上限。 */ +export function capKnowledgeItems( + values: readonly string[], + limit = DEFAULT_ITEM_LIMIT, + maxChars = DEFAULT_TOTAL_CHARS +): string[] { + const normalizedLimit = normalizePositiveInteger(limit, DEFAULT_ITEM_LIMIT); + let remaining = normalizePositiveInteger(maxChars, DEFAULT_TOTAL_CHARS); + const seen = new Set(); + const result: string[] = []; + for (const raw of values) { + const value = raw.trim(); + if (!value || seen.has(value) || remaining <= 0 || result.length >= normalizedLimit) continue; + seen.add(value); + const limited = value.slice(0, remaining); + result.push(limited); + remaining -= limited.length; + } + return result; +} + +/** 将自然语言查询拆为适合代码标识符和中文文本匹配的词元。 */ +export function tokenizeKnowledgeQuery(query: string): string[] { + const tokens = new Set(); + const matches = query.toLowerCase().match(/[\p{Script=Han}]{2,}|[\p{L}\p{N}_./:-]{2,}/gu) ?? []; + for (const match of matches) { + tokens.add(match); + if (/^[\p{Script=Han}]+$/u.test(match) && match.length > 2) { + for (let index = 0; index < match.length - 1; index += 1) { + tokens.add(match.slice(index, index + 2)); + } + } + } + return [...tokens].slice(0, 32); +} + +/** 计算文本与查询词元的简单相关度。 */ +export function scoreKnowledgeText(text: string, tokens: readonly string[]): number { + const normalized = text.toLowerCase(); + let score = 0; + for (const token of tokens) { + if (normalized === token) score += 12; + else if (normalized.includes(token)) score += Math.max(2, Math.min(8, token.length)); + } + return score; +} + +function findCandidateItems(value: unknown): unknown[] { + if (Array.isArray(value)) return value; + if (!value || typeof value !== 'object') return value === null ? [] : [value]; + const record = value as Record; + for (const key of ['results', 'items', 'hits', 'nodes', 'projects', 'matches', 'data']) { + if (Array.isArray(record[key])) return record[key]; + } + return [value]; +} + +function formatCandidate(value: unknown): string { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (!value || typeof value !== 'object') return ''; + const record = value as Record; + const preferredKeys = [ + 'name', + 'qualified_name', + 'title', + 'label', + 'kind', + 'type', + 'signature', + 'path', + 'file', + 'file_path', + 'source_file', + 'source_location', + 'summary', + 'description', + 'snippet', + 'content', + 'score', + 'reason', + ]; + const parts: string[] = []; + for (const key of preferredKeys) { + const formatted = formatScalar(record[key]); + if (formatted) parts.push(`${key}: ${formatted}`); + } + if (parts.length > 0) return parts.join(';'); + + for (const [key, item] of Object.entries(record).slice(0, 8)) { + const formatted = formatScalar(item); + if (formatted) parts.push(`${key}: ${formatted}`); + } + return parts.join(';'); +} + +function formatScalar(value: unknown): string { + if (typeof value === 'string') return value.trim().slice(0, 1600); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) { + return value.map(formatScalar).filter(Boolean).slice(0, 8).join(', '); + } + return ''; +} + +function normalizePositiveInteger(value: number, fallback: number): number { + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +} diff --git a/src/advance/archiver/provider-registry.ts b/src/advance/archiver/provider-registry.ts new file mode 100644 index 0000000..0d5ed06 --- /dev/null +++ b/src/advance/archiver/provider-registry.ts @@ -0,0 +1,88 @@ +import type { + ArchiverProviderAdapter, + ArchiverProviderDescriptor, + ArchiverProviderExecutionStrategy, +} from './provider-types.js'; +import { BuiltinProviderAdapter } from './adapters/builtin-provider-adapter.js'; +import { CodebaseMemoryProviderAdapter } from './adapters/codebase-memory-provider-adapter.js'; +import { GraphifyProviderAdapter } from './adapters/graphify-provider-adapter.js'; +import { RepowiseProviderAdapter } from './adapters/repowise-provider-adapter.js'; +import { UnderstandAnythingProviderAdapter } from './adapters/understand-anything-provider-adapter.js'; + +export class ArchiverProviderRegistry { + private readonly adapters = new Map(); + + constructor(adapters: ArchiverProviderAdapter[] = []) { + for (const adapter of adapters) this.register(adapter); + } + + register(adapter: ArchiverProviderAdapter): void { + this.adapters.set(adapter.descriptor.id, adapter); + } + + get(providerId: string): ArchiverProviderAdapter | undefined { + return this.adapters.get(providerId); + } + + listDescriptors(): ArchiverProviderDescriptor[] { + return Array.from(this.adapters.values()) + .map(adapter => adapter.descriptor) + .sort((left, right) => left.displayName.localeCompare(right.displayName)); + } + + /** + * 根据 Provider 能力和系统优先级生成运行时策略。 + * + * 该结果只存在于当前进程,不写入项目配置;新 Provider 只需声明元数据即可参与遴选。 + */ + createAutomaticStrategy(): ArchiverProviderExecutionStrategy { + const descriptors = [...this.adapters.values()] + .map(adapter => adapter.descriptor) + .filter(descriptor => descriptor.autoSelect !== false); + const primaryCandidates = descriptors + .filter( + descriptor => + descriptor.id !== 'builtin' && + descriptor.automation !== 'manual' && + descriptor.placements.includes('primary') + ) + .sort(compareSelectionPriority); + const primaryIds = primaryCandidates.map(descriptor => descriptor.id); + const enrichers = descriptors + .filter( + descriptor => + descriptor.placements.includes('enricher') && + descriptor.id !== 'builtin' && + !primaryIds.includes(descriptor.id) + ) + .sort(compareSelectionPriority) + .map(descriptor => descriptor.id); + + return { + schemaVersion: 1, + primary: primaryIds[0] ?? 'builtin', + fallbacks: primaryIds.slice(1), + enrichers: ['builtin', ...enrichers], + builtinFallback: true, + overrides: {}, + }; + } +} + +function compareSelectionPriority( + left: ArchiverProviderDescriptor, + right: ArchiverProviderDescriptor +): number { + const priorityDelta = (right.selectionPriority ?? 0) - (left.selectionPriority ?? 0); + return priorityDelta || left.displayName.localeCompare(right.displayName); +} + +export function createDefaultArchiverProviderRegistry(): ArchiverProviderRegistry { + return new ArchiverProviderRegistry([ + new BuiltinProviderAdapter(), + new GraphifyProviderAdapter(), + new CodebaseMemoryProviderAdapter(), + new RepowiseProviderAdapter(), + new UnderstandAnythingProviderAdapter(), + ]); +} diff --git a/src/advance/archiver/provider-shell.ts b/src/advance/archiver/provider-shell.ts new file mode 100644 index 0000000..e2fcff6 --- /dev/null +++ b/src/advance/archiver/provider-shell.ts @@ -0,0 +1,597 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { accessSync, constants, lstatSync, statSync } from 'node:fs'; +import { posix, win32, type PlatformPath } from 'node:path'; + +export type ProviderShellFailureKind = + | 'not-found' + | 'spawn-error' + | 'timeout' + | 'aborted' + | 'exit-code'; + +/** 结构化命令请求,禁止调用方拼接 shell 字符串 */ +export interface ProviderShellRequest { + executable: string; + args?: readonly string[]; + cwd: string; + /** 默认仅继承运行时必需环境;额外变量必须由 Adapter 显式声明 */ + inheritEnv?: readonly string[] | 'all'; + env?: Record; + stdin?: string; + timeoutMs?: number; + maxOutputBytes?: number; + acceptedExitCodes?: readonly number[]; + signal?: AbortSignal; +} + +/** 统一命令执行结果 */ +export interface ProviderShellResult { + success: boolean; + executable?: string; + args: string[]; + exitCode: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + stdoutTruncated: boolean; + stderrTruncated: boolean; + durationMs: number; + failureKind?: ProviderShellFailureKind; + error?: string; +} + +/** Adapter 依赖的最小执行接口,方便测试和未来替换本机、容器或远程后端 */ +export interface ProviderCommandRunner { + run(request: ProviderShellRequest): Promise; +} + +/** 仅本机执行后端需要的可执行文件解析能力,不暴露给 Provider Adapter */ +export interface ProviderExecutableResolver { + resolveExecutable(executable: string, env?: NodeJS.ProcessEnv, cwd?: string): string | null; +} + +export interface ProviderShellOptions { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + defaultTimeoutMs?: number; + defaultMaxOutputBytes?: number; +} + +interface CappedBuffer { + value: Buffer; + truncated: boolean; +} + +interface SpawnInvocation { + command: string; + args: string[]; + windowsVerbatimArguments?: boolean; +} + +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; +const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024; + +/** + * Provider 统一进程执行层。 + * + * 所有命令都通过 executable + args 启动且 shell=false,避免不同 Provider + * 自行处理引号、重定向和平台 shell,从根源上降低注入与转义差异。 + */ +export class ProviderShell implements ProviderCommandRunner, ProviderExecutableResolver { + private readonly baseEnv: NodeJS.ProcessEnv; + private readonly platform: NodeJS.Platform; + private readonly path: PlatformPath; + private readonly defaultTimeoutMs: number; + private readonly defaultMaxOutputBytes: number; + + constructor(options: ProviderShellOptions = {}) { + this.baseEnv = options.env ?? process.env; + this.platform = options.platform ?? process.platform; + this.path = this.platform === 'win32' ? win32 : posix; + this.defaultTimeoutMs = normalizePositiveInteger(options.defaultTimeoutMs, DEFAULT_TIMEOUT_MS); + this.defaultMaxOutputBytes = normalizePositiveInteger( + options.defaultMaxOutputBytes, + DEFAULT_MAX_OUTPUT_BYTES + ); + } + + /** 在显式路径或 PATH 中解析可执行文件,不经过 shell */ + resolveExecutable( + executable: string, + env: NodeJS.ProcessEnv = this.baseEnv, + cwd: string = process.cwd() + ): string | null { + const requested = stripOuterQuotes(executable.trim()); + if (!requested) return null; + + if (this.path.isAbsolute(requested) || requested.includes('/') || requested.includes('\\')) { + return this.resolveExecutableCandidate(this.path.resolve(cwd, requested), env); + } + + const pathDelimiter = this.platform === 'win32' ? ';' : ':'; + const pathEntries = (this.getEnvironmentValue(env, 'PATH') ?? '') + .split(pathDelimiter) + .map(entry => stripOuterQuotes(entry.trim())) + .filter(Boolean); + for (const entry of pathEntries) { + const searchRoot = this.path.isAbsolute(entry) ? entry : this.path.resolve(cwd, entry); + const resolved = this.resolveExecutableCandidate(this.path.join(searchRoot, requested), env); + if (resolved) return resolved; + } + return null; + } + + async run(request: ProviderShellRequest): Promise { + const startedAt = Date.now(); + const args = Array.isArray(request?.args) + ? request.args.filter((argument): argument is string => typeof argument === 'string') + : []; + const validationError = validateRequest(request); + if (validationError) { + return this.failureResult(startedAt, args, 'spawn-error', validationError); + } + + if (!isUsableDirectory(request.cwd, this.path)) { + return this.failureResult( + startedAt, + args, + 'spawn-error', + 'Provider 工作目录必须是存在的绝对目录' + ); + } + + const env = this.buildEnv(request.env, request.inheritEnv); + const executable = this.resolveExecutable(request.executable, env, request.cwd); + if (!executable) { + return this.failureResult( + startedAt, + args, + 'not-found', + `找不到可执行文件: ${request.executable}` + ); + } + + if (request.signal?.aborted) { + return this.failureResult( + startedAt, + args, + 'aborted', + 'Provider 命令在启动前已取消', + executable + ); + } + + const timeoutMs = normalizePositiveInteger(request.timeoutMs, this.defaultTimeoutMs); + const maxOutputBytes = normalizePositiveInteger( + request.maxOutputBytes, + this.defaultMaxOutputBytes + ); + const acceptedExitCodes = new Set(request.acceptedExitCodes ?? [0]); + const invocation = this.buildSpawnInvocation(executable, args, env); + if (!invocation) { + return this.failureResult( + startedAt, + args, + 'spawn-error', + 'Windows 脚本需要可用的 cmd.exe 启动器', + executable + ); + } + + return new Promise(resolveResult => { + let child: ChildProcess; + try { + child = spawn(invocation.command, invocation.args, { + cwd: request.cwd, + env, + shell: false, + windowsHide: true, + detached: this.platform !== 'win32', + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + resolveResult(this.failureResult(startedAt, args, 'spawn-error', message, executable)); + return; + } + + let stdout: CappedBuffer = { value: Buffer.alloc(0), truncated: false }; + let stderr: CappedBuffer = { value: Buffer.alloc(0), truncated: false }; + let settled = false; + let failureKind: ProviderShellFailureKind | undefined; + let failureMessage: string | undefined; + + const settle = (exitCode: number | null, signal: NodeJS.Signals | null) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + request.signal?.removeEventListener('abort', abortHandler); + + const accepted = exitCode !== null && acceptedExitCodes.has(exitCode); + const finalFailureKind = failureKind ?? (accepted ? undefined : 'exit-code'); + const finalError = + failureMessage ?? + (finalFailureKind === 'exit-code' + ? `Provider 命令退出码为 ${exitCode ?? 'null'}` + : undefined); + resolveResult({ + success: !finalFailureKind, + executable, + args, + exitCode, + signal, + stdout: stdout.value.toString('utf8'), + stderr: stderr.value.toString('utf8'), + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + durationMs: Date.now() - startedAt, + failureKind: finalFailureKind, + error: finalError, + }); + }; + + const terminate = (kind: ProviderShellFailureKind, message: string) => { + if (settled || failureKind) return; + failureKind = kind; + failureMessage = message; + this.terminate(child); + }; + + const timeout = setTimeout(() => { + terminate('timeout', `Provider 命令执行超时(${timeoutMs}ms)`); + }, timeoutMs); + const abortHandler = () => terminate('aborted', 'Provider 命令已取消'); + request.signal?.addEventListener('abort', abortHandler, { once: true }); + if (request.signal?.aborted) abortHandler(); + + child.stdout?.on('data', (chunk: Buffer | string) => { + stdout = appendCapped(stdout, chunk, maxOutputBytes); + }); + child.stderr?.on('data', (chunk: Buffer | string) => { + stderr = appendCapped(stderr, chunk, maxOutputBytes); + }); + child.on('error', error => { + failureKind = failureKind ?? 'spawn-error'; + failureMessage = failureMessage ?? error.message; + }); + child.on('close', settle); + + child.stdin?.on('error', () => undefined); + if (request.stdin !== undefined) { + child.stdin?.end(request.stdin); + } else { + child.stdin?.end(); + } + }); + } + + private buildEnv( + overrides?: Record, + inheritEnv?: readonly string[] | 'all' + ): NodeJS.ProcessEnv { + const env = + inheritEnv === 'all' + ? { ...this.baseEnv } + : this.pickEnvironment([...RUNTIME_ENVIRONMENT_KEYS, ...(inheritEnv ?? [])]); + for (const [key, value] of Object.entries(overrides ?? {})) { + const targetKey = + this.platform === 'win32' + ? (Object.keys(env).find(candidate => candidate.toLowerCase() === key.toLowerCase()) ?? + key) + : key; + if (value === undefined) { + delete env[targetKey]; + } else { + env[targetKey] = value; + } + } + return env; + } + + private pickEnvironment(keys: readonly string[]): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const key of keys) { + const value = this.getEnvironmentValue(this.baseEnv, key); + if (value === undefined) continue; + const sourceKey = + this.platform === 'win32' + ? Object.keys(this.baseEnv).find( + candidate => candidate.toLowerCase() === key.toLowerCase() + ) + : key; + env[sourceKey ?? key] = value; + } + return env; + } + + private buildSpawnInvocation( + executable: string, + args: string[], + env: NodeJS.ProcessEnv + ): SpawnInvocation | null { + if (this.platform !== 'win32' || !/\.(?:cmd|bat)$/i.test(executable)) { + return { command: executable, args }; + } + + const commandInterpreter = this.resolveExecutable( + this.getEnvironmentValue(env, 'ComSpec') ?? 'cmd.exe', + env + ); + if (!commandInterpreter) return null; + + const doubleEscape = /node_modules[\\/]\.bin[\\/][^\\/]+\.(?:cmd|bat)$/i.test(executable); + const commandLine = [ + escapeWindowsCommand(executable), + ...args.map(arg => escapeWindowsArgument(arg, doubleEscape)), + ].join(' '); + return { + command: commandInterpreter, + args: ['/d', '/s', '/c', `${commandLine}`], + windowsVerbatimArguments: true, + }; + } + + private resolveExecutableCandidate(candidate: string, env: NodeJS.ProcessEnv): string | null { + const candidates = + this.platform === 'win32' && !this.path.extname(candidate) + ? this.windowsExecutableCandidates(candidate, env) + : [candidate]; + for (const path of candidates) { + try { + accessSync(path, this.platform === 'win32' ? constants.F_OK : constants.X_OK); + if (statSync(path).isFile()) return path; + } catch { + if (this.platform === 'win32') { + try { + if (lstatSync(path).isSymbolicLink()) return path; + } catch { + // Windows 应用执行别名可能拒绝 stat,继续检查其它候选。 + } + } + continue; + } + } + return null; + } + + private windowsExecutableCandidates(candidate: string, env: NodeJS.ProcessEnv): string[] { + const pathExt = this.getEnvironmentValue(env, 'PATHEXT') ?? '.COM;.EXE;.BAT;.CMD'; + return pathExt + .split(';') + .map(extension => extension.trim()) + .filter(Boolean) + .map(extension => `${candidate}${extension}`); + } + + private getEnvironmentValue(env: NodeJS.ProcessEnv, key: string): string | undefined { + if (this.platform !== 'win32') return env[key]; + const actualKey = Object.keys(env).find( + candidate => candidate.toLowerCase() === key.toLowerCase() + ); + return actualKey ? env[actualKey] : undefined; + } + + private terminate(child: ChildProcess): void { + if (child.exitCode !== null || child.signalCode !== null) return; + const forceTimer = setTimeout(() => { + if (child.exitCode !== null || child.signalCode !== null) return; + this.signalProcessTree(child, 'SIGKILL'); + }, 1000); + forceTimer.unref(); + + const pid = child.pid; + if (this.platform === 'win32' && pid) { + const taskkill = this.resolveExecutable('taskkill.exe'); + if (taskkill) { + try { + const killer = spawn(taskkill, ['/PID', String(pid), '/T', '/F'], { + shell: false, + windowsHide: true, + stdio: 'ignore', + }); + killer.once('error', () => this.signalProcessTree(child, 'SIGTERM')); + killer.once('close', exitCode => { + if (exitCode !== 0) this.signalProcessTree(child, 'SIGTERM'); + }); + killer.unref(); + return; + } catch { + // 终止器启动失败时继续使用直接信号。 + } + } + } + + this.signalProcessTree(child, 'SIGTERM'); + } + + private signalProcessTree(child: ChildProcess, signal: NodeJS.Signals): void { + if (this.platform !== 'win32' && child.pid) { + try { + process.kill(-child.pid, signal); + return; + } catch { + // 子进程可能在建立进程组前退出,继续尝试直接终止。 + } + } + try { + child.kill(signal); + } catch { + // 子进程可能已在终止流程中退出。 + } + } + + private failureResult( + startedAt: number, + args: string[], + failureKind: ProviderShellFailureKind, + error: string, + executable?: string + ): ProviderShellResult { + return { + success: false, + executable, + args, + exitCode: null, + signal: null, + stdout: '', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + durationMs: Date.now() - startedAt, + failureKind, + error, + }; + } +} + +function validateRequest(request: ProviderShellRequest): string | null { + if (!request || typeof request !== 'object') return 'Provider 命令请求无效'; + if (typeof request.executable !== 'string' || !request.executable.trim()) { + return 'Provider 可执行文件不能为空'; + } + if (request.executable.includes('\0')) return 'Provider 可执行文件包含非法空字符'; + if (typeof request.cwd !== 'string' || !request.cwd.trim()) return 'Provider 工作目录不能为空'; + if (request.cwd.includes('\0')) return 'Provider 工作目录包含非法空字符'; + if ( + request.args !== undefined && + (!Array.isArray(request.args) || request.args.some(argument => typeof argument !== 'string')) + ) { + return 'Provider 参数必须是字符串数组'; + } + if (request.args?.some(argument => argument.includes('\0'))) { + return 'Provider 参数包含非法空字符'; + } + if (request.stdin !== undefined && typeof request.stdin !== 'string') { + return 'Provider 标准输入必须是字符串'; + } + if (request.stdin?.includes('\0')) return 'Provider 标准输入包含非法空字符'; + if ( + request.inheritEnv !== undefined && + request.inheritEnv !== 'all' && + (!Array.isArray(request.inheritEnv) || request.inheritEnv.some(key => typeof key !== 'string')) + ) { + return 'Provider 继承环境变量必须是字符串数组'; + } + if (request.inheritEnv !== 'all') { + for (const key of request.inheritEnv ?? []) { + if (!isEnvironmentVariableName(key)) return `Provider 继承环境变量名无效: ${key}`; + } + } + if ( + request.env !== undefined && + (!request.env || typeof request.env !== 'object' || Array.isArray(request.env)) + ) { + return 'Provider 环境变量必须是对象'; + } + for (const [key, value] of Object.entries(request.env ?? {})) { + if (!isEnvironmentVariableName(key)) return `Provider 环境变量名无效: ${key}`; + if (value !== undefined && typeof value !== 'string') + return `Provider 环境变量 ${key} 必须是字符串`; + if (value?.includes('\0')) return `Provider 环境变量 ${key} 包含非法空字符`; + } + if ( + request.acceptedExitCodes !== undefined && + (!Array.isArray(request.acceptedExitCodes) || + request.acceptedExitCodes.some(code => typeof code !== 'number' || !Number.isInteger(code))) + ) { + return 'Provider 可接受退出码必须是整数数组'; + } + if ( + request.timeoutMs !== undefined && + (typeof request.timeoutMs !== 'number' || + !Number.isFinite(request.timeoutMs) || + request.timeoutMs <= 0) + ) { + return 'Provider 超时必须是大于零的有限数值'; + } + if ( + request.maxOutputBytes !== undefined && + (typeof request.maxOutputBytes !== 'number' || + !Number.isFinite(request.maxOutputBytes) || + request.maxOutputBytes <= 0) + ) { + return 'Provider 输出上限必须是大于零的有限数值'; + } + return null; +} + +function isUsableDirectory(path: string, pathApi: PlatformPath): boolean { + if (!pathApi.isAbsolute(path)) return false; + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function isEnvironmentVariableName(value: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); +} + +function normalizePositiveInteger(value: number | undefined, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? Math.min(Math.floor(value), 2_147_483_647) + : fallback; +} + +const RUNTIME_ENVIRONMENT_KEYS = [ + 'PATH', + 'PATHEXT', + 'ComSpec', + 'SystemRoot', + 'WINDIR', + 'SystemDrive', + 'HOME', + 'USERPROFILE', + 'LOCALAPPDATA', + 'APPDATA', + 'PROGRAMDATA', + 'TMP', + 'TEMP', + 'TMPDIR', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + 'TERM', +] as const; + +function stripOuterQuotes(value: string): string { + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { + return value.slice(1, -1); + } + return value; +} + +function appendCapped( + current: CappedBuffer, + chunk: Buffer | string, + maxBytes: number +): CappedBuffer { + const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const combined = Buffer.concat([current.value, incoming]); + if (combined.byteLength <= maxBytes) { + return { value: combined, truncated: current.truncated }; + } + return { + value: combined.subarray(combined.byteLength - maxBytes), + truncated: true, + }; +} + +const WINDOWS_META_CHARACTERS = /([()\][%!^"\x60<>&|;, *?])/g; + +function escapeWindowsCommand(value: string): string { + return value.replace(WINDOWS_META_CHARACTERS, '^$1'); +} + +function escapeWindowsArgument(value: string, doubleEscapeMetaCharacters: boolean): string { + let escaped = String(value); + escaped = escaped.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"'); + escaped = escaped.replace(/(?=(\\+?)?)\1$/, '$1$1'); + escaped = `"${escaped}"`.replace(WINDOWS_META_CHARACTERS, '^$1'); + if (doubleEscapeMetaCharacters) { + escaped = escaped.replace(WINDOWS_META_CHARACTERS, '^$1'); + } + return escaped; +} diff --git a/src/advance/archiver/provider-types.ts b/src/advance/archiver/provider-types.ts new file mode 100644 index 0000000..cdb95cd --- /dev/null +++ b/src/advance/archiver/provider-types.ts @@ -0,0 +1,267 @@ +import type { Project, Role } from '../types.js'; + +/** Provider 内部选项值,不进入项目持久化配置。 */ +export type ArchiverProviderOptionValue = string | number | boolean; + +/** Provider 内部运行覆盖,不通过项目配置或 UI 暴露。 */ +export interface ArchiverProviderOverride { + launchPreset?: string; + executable?: string; + argsPrefix?: string[]; + timeoutMs?: number; + inheritEnv?: string[]; + env?: Record; + options?: Record; +} + +/** 编排器内部执行策略,与 Archiver 持久化配置隔离。 */ +export interface ArchiverProviderExecutionStrategy { + schemaVersion: 1; + primary: string; + fallbacks: string[]; + enrichers: string[]; + builtinFallback: boolean; + overrides?: Record; +} + +/** Provider 能力标识,供编排器和角色上下文层做能力协商 */ +export type ArchiverProviderCapability = + | 'code-structure' + | 'documents' + | 'query' + | 'impact-analysis' + | 'git-history' + | 'code-health' + | 'interactive-skill'; + +/** Provider 自动化接入等级 */ +export type ArchiverProviderAutomation = 'full' | 'managed' | 'manual'; + +/** Provider 运行时准备方式,由系统托管,不进入项目配置。 */ +export type ArchiverProviderManagedRuntime = + | { + kind: 'python-package'; + packageName: string; + version: string; + entrypoint: string; + } + | { + kind: 'npm-package'; + packageName: string; + version: string; + entrypoint: string; + } + | { + kind: 'git-skill'; + repository: string; + revision: string; + version: string; + skillPath: string; + }; + +/** Provider 对 UI 暴露的准备能力,不暴露包名、命令或本机路径。 */ +export type ArchiverProviderPreparation = 'builtin' | 'managed' | 'manual'; + +/** Provider 当前可执行状态。 */ +export type ArchiverProviderReadiness = + | 'ready' + | 'preparable' + | 'preparing' + | 'manual' + | 'unavailable'; + +/** Provider 可承担的组合位置 */ +export type ArchiverProviderPlacement = 'primary' | 'fallback' | 'enricher'; + +/** Provider 的标准启动方式,包管理器差异不进入具体命令语义 */ +export interface ArchiverProviderLaunchPreset { + id: string; + displayName: string; + description?: string; + executable: string; + argsPrefix: string[]; + /** 该启动器运行所需、由 Adapter 显式声明继承的宿主环境变量 */ + inheritEnv?: string[]; +} + +/** Provider 自有选项的 UI 描述,由 Adapter 负责解释具体语义 */ +export interface ArchiverProviderOptionDescriptor { + key: string; + displayName: string; + description?: string; + type: 'boolean' | 'number' | 'string'; + defaultValue: string | number | boolean; +} + +/** 对 UI 和编排器公开的 Provider 元数据 */ +export interface ArchiverProviderDescriptor { + id: string; + displayName: string; + description: string; + homepage: string; + license: string; + kind: 'builtin' | 'cli' | 'skill'; + automation: ArchiverProviderAutomation; + placements: ArchiverProviderPlacement[]; + capabilities: ArchiverProviderCapability[]; + /** 是否参与系统自动遴选;仅供内部 Registry 使用。 */ + autoSelect?: boolean; + /** 同一位置下的系统优先级,数值越大越优先。 */ + selectionPriority?: number; + defaultExecutable?: string; + defaultLaunchPreset?: string; + launchPresets?: ArchiverProviderLaunchPreset[]; + options?: ArchiverProviderOptionDescriptor[]; + licenseNotice?: string; + /** 系统托管运行时描述,仅供主进程准备 Provider。 */ + managedRuntime?: ArchiverProviderManagedRuntime; +} + +/** Provider 探测状态 */ +export interface ArchiverProviderProbeResult { + providerId: string; + available: boolean; + readiness?: ArchiverProviderReadiness; + prepared?: boolean; + executable?: string; + version?: string; + message?: string; +} + +/** 托管运行时解析出的 CLI 启动信息。 */ +export interface ArchiverProviderManagedInvocation { + executable: string; + argsPrefix: string[]; + env?: Record; + inheritEnv?: string[]; + version: string; +} + +/** Provider 自动准备结果。 */ +export interface ArchiverProviderPrepareResult { + providerId: string; + success: boolean; + prepared: boolean; + manual?: boolean; + version?: string; + message?: string; + invocation?: ArchiverProviderManagedInvocation; + assetRoot?: string; +} + +/** Provider 托管运行时能力。 */ +export interface ArchiverProviderProvisioner { + resolve(descriptor: ArchiverProviderDescriptor): Promise; + prepare(descriptor: ArchiverProviderDescriptor): Promise; +} + +/** Provider 单次同步结果 */ +export interface ArchiverProviderSyncResult { + providerId: string; + success: boolean; + skipped?: boolean; + message?: string; + artifacts?: string[]; + metadata?: Record; +} + +/** Provider 静态上下文请求 */ +export interface ArchiverProviderContextRequest { + maxChars?: number; +} + +/** Provider 静态上下文结果 */ +export interface ArchiverProviderContextResult { + providerId: string; + success: boolean; + content?: string; + message?: string; +} + +/** Provider 项目知识查询请求 */ +export interface ArchiverProviderQueryRequest { + query: string; + role?: Role; + limit?: number; + maxChars?: number; +} + +/** Provider 项目知识查询结果 */ +export interface ArchiverProviderQueryResult { + providerId: string; + success: boolean; + items: string[]; + message?: string; +} + +/** Provider 同步上下文 */ +export interface ArchiverProviderSyncContext { + project: Project; + archiveRoot: string; + providerDataRoot: string; +} + +/** 编排器记录的单个 Provider 状态 */ +export interface ArchiverProviderRunStatus { + providerId: string; + placement: ArchiverProviderPlacement; + state: 'selected' | 'completed' | 'unavailable' | 'failed' | 'skipped' | 'deferred'; + startedAt: number; + finishedAt: number; + version?: string; + message?: string; + artifacts?: string[]; +} + +/** 一轮 Provider 编排报告 */ +export interface ArchiverProviderRunReport { + schemaVersion: 1; + projectId: string; + generatedAt: number; + selectedPrimary: string; + statuses: ArchiverProviderRunStatus[]; +} + +/** Provider Adapter 运行时依赖 */ +export interface ArchiverProviderRuntime { + shell: import('./provider-shell.js').ProviderCommandRunner; + provisioner?: ArchiverProviderProvisioner; +} + +/** Provider Adapter 协议 */ +export interface ArchiverProviderAdapter { + readonly descriptor: ArchiverProviderDescriptor; + prepare?( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + override?: ArchiverProviderOverride + ): Promise; + probe( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + override?: ArchiverProviderOverride + ): Promise; + sync( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + override?: ArchiverProviderOverride + ): Promise; + /** 判断已有归档产物是否足以提供查询/上下文,允许上一轮成功结果继续可用。 */ + isReady?( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + override?: ArchiverProviderOverride + ): Promise; + loadContext?( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + request: ArchiverProviderContextRequest, + override?: ArchiverProviderOverride + ): Promise; + query?( + context: ArchiverProviderSyncContext, + runtime: ArchiverProviderRuntime, + request: ArchiverProviderQueryRequest, + override?: ArchiverProviderOverride + ): Promise; +} diff --git a/src/advance/classic/agent-entries/role-entry.ts b/src/advance/classic/agent-entries/role-entry.ts index ed99cf7..5529057 100755 --- a/src/advance/classic/agent-entries/role-entry.ts +++ b/src/advance/classic/agent-entries/role-entry.ts @@ -112,6 +112,7 @@ async function main() { const runner = createRoleRunner(config.role, { llmClient, mcpUrl: process.env.CK_EVEROS_MCP_URL, + codeGraphUrl: process.env.CK_CODEGRAPH_SERVER_URL, }); // 子进程独立打开数据库,周期性读取启用项目并同步 Agent 循环 diff --git a/src/advance/classic/archive/archiver-brain.ts b/src/advance/classic/archive/archiver-brain.ts index 7681006..fd815df 100755 --- a/src/advance/classic/archive/archiver-brain.ts +++ b/src/advance/classic/archive/archiver-brain.ts @@ -5,7 +5,11 @@ import { defaultPromptLoader, type PromptLoader } from '../../llm/prompts/loader export interface ProjectAnalyzer { name: string; - analyze(project: Project, files: string[]): Promise; + analyze( + project: Project, + files: string[], + fileContents?: Record + ): Promise; } export interface ArchiverBrainOptions { @@ -39,13 +43,19 @@ export class ArchiverBrain { private readonly analyzers: ProjectAnalyzer[]; constructor(options: ArchiverBrainOptions) { - this.analyzers = options.analyzers ?? [new LlmProjectAnalyzer(options.llmClient, options.promptLoader)]; + this.analyzers = options.analyzers ?? [ + new LlmProjectAnalyzer(options.llmClient, options.promptLoader), + ]; } - async analyzeProject(project: Project, files: string[]): Promise { + async analyzeProject( + project: Project, + files: string[], + fileContents?: Record + ): Promise { const all: ProjectKnowledgeItem[] = []; for (const analyzer of this.analyzers) { - const items = await analyzer.analyze(project, files); + const items = await analyzer.analyze(project, files, fileContents); all.push(...items); } return this.deduplicate(all); @@ -53,7 +63,7 @@ export class ArchiverBrain { private deduplicate(items: ProjectKnowledgeItem[]): ProjectKnowledgeItem[] { const seen = new Set(); - return items.filter((item) => { + return items.filter(item => { if (seen.has(item.id)) return false; seen.add(item.id); return true; @@ -68,55 +78,109 @@ export class LlmProjectAnalyzer implements ProjectAnalyzer { name = 'llm'; private readonly promptLoader: PromptLoader; - constructor(private readonly llmClient: LlmClient, promptLoader?: PromptLoader) { + constructor( + private readonly llmClient: LlmClient, + promptLoader?: PromptLoader + ) { this.promptLoader = promptLoader ?? defaultPromptLoader; } - async analyze(project: Project, files: string[]): Promise { + async analyze( + project: Project, + files: string[], + fileContents?: Record + ): Promise { const keyFiles = selectArchiverInputFiles(files); if (keyFiles.length === 0) return []; - const prompt = this.buildPrompt(project, keyFiles); + const prompt = this.buildPrompt(project, keyFiles, fileContents); const system = `你是项目知识整理助手。${this.promptLoader.load('shared/json-only-constraint')}`; const raw = await this.llmClient.complete(prompt, system); return this.parseResponse(raw); } - private buildPrompt(project: Project, files: string[]): string { + private buildPrompt( + project: Project, + files: string[], + fileContents?: Record + ): string { return this.promptLoader.load('archiver-analyze', { projectName: project.name, projectRootPath: project.rootPath, - filePaths: files.map((f) => `- ${f}`).join('\n'), + filePaths: files.map(f => `- ${f}`).join('\n'), + fileContents: formatFileContents(files, fileContents), }); } private parseResponse(raw: string): ProjectKnowledgeItem[] { try { const cleaned = raw.replace(/```(?:json)?\s*([\s\S]*?)```/, '$1').trim(); - const parsed = JSON.parse(cleaned) as Array>; - return parsed.map((item, index) => ({ - id: String(item.id ?? `knowledge-${index}`), - category: this.normalizeCategory(String(item.category ?? 'convention')), - sourceFiles: Array.isArray(item.sourceFiles) ? item.sourceFiles.map(String) : [], - content: String(item.content ?? ''), - confidence: this.normalizeConfidence(String(item.confidence ?? 'medium')), - createdAt: new Date().toISOString(), - })); + const parsed = JSON.parse(cleaned) as unknown; + if (!Array.isArray(parsed)) { + throw new Error('响应根节点必须是数组'); + } + return parsed.map((item, index) => { + const record = readRecord(item); + return { + id: String(record.id ?? `knowledge-${index}`), + category: this.normalizeCategory(String(record.category ?? 'convention')), + sourceFiles: Array.isArray(record.sourceFiles) ? record.sourceFiles.map(String) : [], + content: String(record.content ?? ''), + confidence: this.normalizeConfidence(String(record.confidence ?? 'medium')), + createdAt: new Date().toISOString(), + }; + }); } catch (err) { - console.error('[LlmProjectAnalyzer] 解析失败:', err, '原始响应:', raw); - return []; + throw new Error('Archiver 知识响应解析失败', { cause: err }); } } private normalizeCategory(category: string): ProjectKnowledgeItem['category'] { - const valid: ProjectKnowledgeItem['category'][] = ['convention', 'architecture', 'domain', 'risk', 'stack', 'graph']; + const valid: ProjectKnowledgeItem['category'][] = [ + 'convention', + 'architecture', + 'domain', + 'risk', + 'stack', + 'graph', + ]; const lower = category.toLowerCase(); - return valid.includes(lower as ProjectKnowledgeItem['category']) ? (lower as ProjectKnowledgeItem['category']) : 'convention'; + return valid.includes(lower as ProjectKnowledgeItem['category']) + ? (lower as ProjectKnowledgeItem['category']) + : 'convention'; } private normalizeConfidence(confidence: string): ProjectKnowledgeItem['confidence'] { const valid: ProjectKnowledgeItem['confidence'][] = ['low', 'medium', 'high']; const lower = confidence.toLowerCase(); - return valid.includes(lower as ProjectKnowledgeItem['confidence']) ? (lower as ProjectKnowledgeItem['confidence']) : 'medium'; + return valid.includes(lower as ProjectKnowledgeItem['confidence']) + ? (lower as ProjectKnowledgeItem['confidence']) + : 'medium'; } } + +function readRecord(value: unknown): Record { + return value && typeof value === 'object' ? (value as Record) : {}; +} + +const MAX_FILE_CONTENT_CHARS = 12_000; +const MAX_TOTAL_CONTENT_CHARS = 40_000; + +function formatFileContents( + files: string[], + fileContents?: Record +): string { + let remaining = MAX_TOTAL_CONTENT_CHARS; + const sections: string[] = []; + for (const file of files) { + if (remaining <= 0) break; + const raw = fileContents?.[file]; + const content = raw === undefined ? '[内容不可读]' : raw; + const limited = content.slice(0, Math.min(MAX_FILE_CONTENT_CHARS, remaining)); + remaining -= limited.length; + sections.push( + `### ${file}\n\n${limited}${limited.length < content.length ? '\n\n[内容已截断]' : ''}` + ); + } + return sections.join('\n\n'); +} diff --git a/src/advance/classic/memory/recall-planner.ts b/src/advance/classic/memory/recall-planner.ts index 9ebab44..9e2344a 100755 --- a/src/advance/classic/memory/recall-planner.ts +++ b/src/advance/classic/memory/recall-planner.ts @@ -4,9 +4,10 @@ import type { ToolDefinition } from '../../llm/tool-types.js'; import type { IMemoryClient } from './types.js'; import { logMemorySnapshot } from '../utils/memory-snapshot.js'; import { defaultPromptLoader, type PromptLoader } from '../../llm/prompts/loader.js'; +import type { ProjectKnowledgeSource } from '../../archiver/project-knowledge-source.js'; const RECALL_DECISION_TOOL: ToolDefinition = { name: 'recall_decision', - description: '判断当前任务是否需要查询历史记忆,以及需要查询哪些类型', + description: '判断当前任务是否需要查询项目知识与历史记忆,以及需要查询哪些类型', input_schema: { type: 'object', properties: { @@ -67,7 +68,9 @@ export interface RecallPlannerOptions { /** LLM 客户端 */ llmClient: LlmClient; /** 记忆客户端 */ - memoryClient: IMemoryClient; + memoryClient?: IMemoryClient; + /** Archiver Provider 提供的当前项目知识源 */ + projectKnowledgeSource?: ProjectKnowledgeSource; /** 可用的 recall 类型白名单,默认全开 */ enabledTypes?: RecallType[]; /** 自定义决策提示词(覆盖默认) */ @@ -100,12 +103,13 @@ const ALL_RECALL_TYPES: RecallType[] = [ /** * 记忆查询规划器 * - * 让 Agent 自己决定是否需要查询记忆、查询哪类记忆、query 是什么。 + * 让 Agent 自己决定是否需要查询项目知识或历史记忆、查询哪类内容、query 是什么。 * 执行时按需调用 memoryClient 的对应 recall 方法,而不是一次性全查。 */ export class RecallPlanner { private readonly llmClient: LlmClient; - private readonly memoryClient: IMemoryClient; + private readonly memoryClient?: IMemoryClient; + private readonly projectKnowledgeSource?: ProjectKnowledgeSource; private readonly enabledTypes: Set; private readonly customDecisionPrompt?: string; private readonly promptLoader: PromptLoader; @@ -113,7 +117,11 @@ export class RecallPlanner { constructor(options: RecallPlannerOptions) { this.llmClient = options.llmClient; this.memoryClient = options.memoryClient; - this.enabledTypes = new Set(options.enabledTypes ?? ALL_RECALL_TYPES); + this.projectKnowledgeSource = options.projectKnowledgeSource; + const availableTypes = this.getAvailableTypes(); + this.enabledTypes = new Set( + (options.enabledTypes ?? ALL_RECALL_TYPES).filter(type => availableTypes.has(type)) + ); this.customDecisionPrompt = options.decisionPrompt; this.promptLoader = options.promptLoader ?? defaultPromptLoader; } @@ -147,7 +155,7 @@ export class RecallPlanner { return []; } - const validQueries = plan.queries.filter((q) => { + const validQueries = plan.queries.filter(q => { if (!this.enabledTypes.has(q.type)) { logger.warn({ query: q }, 'RecallPlanner 忽略未启用的 recall 类型'); return false; @@ -170,7 +178,7 @@ export class RecallPlanner { logMemorySnapshot('RecallPlanner.execute 开始'); const results = await Promise.all( - validQueries.map(async (q) => { + validQueries.map(async q => { try { const items = await this.executeQuery(q); return items; @@ -183,7 +191,9 @@ export class RecallPlanner { ); const flat = results.flat(); - console.log(`[RecallPlanner] 召回总条目=${flat.length}, 总字符=${flat.reduce((sum, m) => sum + m.length, 0)}`); + console.log( + `[RecallPlanner] 召回总条目=${flat.length}, 总字符=${flat.reduce((sum, m) => sum + m.length, 0)}` + ); logMemorySnapshot('RecallPlanner.execute 结束'); return flat; } @@ -191,13 +201,13 @@ export class RecallPlanner { private async executeQuery(query: RecallQuery): Promise { switch (query.type) { case 'review': - return this.memoryClient.recallForReview(query.query); + return this.memoryClient?.recallForReview(query.query) ?? []; case 'maintenance': - return this.memoryClient.recallForMaintenance(query.query); + return this.memoryClient?.recallForMaintenance(query.query) ?? []; case 'project_knowledge': - return this.memoryClient.recallProjectKnowledge(query.query); + return this.recallProjectKnowledge(query.query); case 'user_preferences': - if (!query.userId) { + if (!query.userId || !this.memoryClient) { return []; } return this.memoryClient.recallUserPreferences(query.userId, query.query); @@ -221,20 +231,54 @@ export class RecallPlanner { availableFindings: input.availableFindings ? `\n当前已知的 findings:\n${input.availableFindings}` : '', + availableRecallTypes: [...this.enabledTypes].join(', ') || '无', }); } + private async recallProjectKnowledge(query: string): Promise { + const [memoryItems, providerItems] = await Promise.all([ + this.memoryClient + ? this.memoryClient.recallProjectKnowledge(query).catch(error => { + logger.warn( + { err: error instanceof Error ? error.message : String(error) }, + 'RecallPlanner 项目记忆查询失败' + ); + return []; + }) + : Promise.resolve([]), + this.projectKnowledgeSource + ? this.projectKnowledgeSource.query(query).catch(error => { + logger.warn( + { err: error instanceof Error ? error.message : String(error) }, + 'RecallPlanner Provider 项目知识查询失败' + ); + return []; + }) + : Promise.resolve([]), + ]); + return [...new Set([...memoryItems, ...providerItems])]; + } + + private getAvailableTypes(): Set { + const types = new Set(); + if (this.memoryClient) { + for (const type of ALL_RECALL_TYPES) types.add(type); + } + if (this.projectKnowledgeSource) types.add('project_knowledge'); + return types; + } + private parsePlan(input: Record): RecallPlan { try { const rawQueries = Array.isArray(input.queries) ? input.queries : []; const queries = rawQueries .filter((q): q is Record => typeof q === 'object' && q !== null) - .map((q) => ({ + .map(q => ({ type: this.normalizeRecallType(String(q.type ?? '')), query: String(q.query ?? ''), userId: q.userId ? String(q.userId) : undefined, })) - .filter((q) => q.type !== undefined && q.query.trim().length > 0) as RecallQuery[]; + .filter(q => q.type !== undefined && q.query.trim().length > 0) as RecallQuery[]; return { needsRecall: input.needsRecall === true && queries.length > 0, @@ -253,6 +297,6 @@ export class RecallPlanner { private normalizeRecallType(type: string): RecallType | undefined { const valid: RecallType[] = ['review', 'maintenance', 'project_knowledge', 'user_preferences']; const lower = type.toLowerCase().trim(); - return valid.find((v) => v === lower || v.replace(/_/g, '') === lower.replace(/_/g, '')); + return valid.find(v => v === lower || v.replace(/_/g, '') === lower.replace(/_/g, '')); } } diff --git a/src/advance/classic/review/reviewer-brain.ts b/src/advance/classic/review/reviewer-brain.ts index 3931a03..5a26213 100755 --- a/src/advance/classic/review/reviewer-brain.ts +++ b/src/advance/classic/review/reviewer-brain.ts @@ -71,11 +71,10 @@ export class ReviewerBrain { * 对 LGTM、emoji、纯感谢等无需回复的内容返回 shouldReply=false。 */ async replyToComment(input: ReviewerReplyInput): Promise { - const threadContext = await summarizeThreadNotes( - this.options.llmClient, - input.threadNotes, - { maxRawTokens: 8000, maxRecentItems: 5 } - ); + const threadContext = await summarizeThreadNotes(this.options.llmClient, input.threadNotes, { + maxRawTokens: 8000, + maxRecentItems: 5, + }); const recalledContext = await this.recallContextForReply(input, threadContext); const prompt = this.buildReplyPrompt(input, threadContext, recalledContext); const response = await this.options.llmClient.completeJson( @@ -91,26 +90,27 @@ export class ReviewerBrain { ): Promise { if (!this.options.recallPlanner) return ''; const findingsText = input.originalFindings - .map( - (f) => - `- [${f.severity}] \`${f.file}:${f.line}\` ${f.message}\n 建议:${f.suggestion}` - ) + .map(f => `- [${f.severity}] \`${f.file}:${f.line}\` ${f.message}\n 建议:${f.suggestion}`) .join('\n'); const plan = await this.options.recallPlanner.plan({ role: 'reviewer', taskType: 'reply', - taskSummary: `${formatThreadContext(threadContext)}\n\n待回复评论:${input.targetNote.body}`.slice(0, 3000), + taskSummary: + `${formatThreadContext(threadContext)}\n\n待回复评论:${input.targetNote.body}`.slice( + 0, + 3000 + ), availableFindings: findingsText, }); if (!plan.needsRecall || plan.queries.length === 0) return ''; const memories = await this.options.recallPlanner.execute(plan); if (memories.length === 0) return ''; - return `\n\n相关历史记忆:\n${memories.map((m) => `- ${m}`).join('\n')}`; + return `\n\n相关项目知识与历史记忆:\n${memories.map(m => `- ${m}`).join('\n')}`; } private async recallContext(mr: MergeRequest, diffs: MrDiff[]): Promise { if (!this.options.recallPlanner) return ''; - const diffSummary = diffs.map((d) => `${d.newPath}\n${d.diff}`).join('\n'); + const diffSummary = diffs.map(d => `${d.newPath}\n${d.diff}`).join('\n'); const plan = await this.options.recallPlanner.plan({ role: 'reviewer', taskType: 'review', @@ -119,13 +119,13 @@ export class ReviewerBrain { if (!plan.needsRecall || plan.queries.length === 0) return ''; const memories = await this.options.recallPlanner.execute(plan); if (memories.length === 0) return ''; - return `\n\n相关历史记忆:\n${memories.map((m) => `- ${m}`).join('\n')}`; + return `\n\n相关项目知识与历史记忆:\n${memories.map(m => `- ${m}`).join('\n')}`; } private buildReviewPrompt(mr: MergeRequest, diffs: MrDiff[], recalledContext: string): string { const diffText = diffs .map( - (d) => + d => `--- ${d.oldPath}\n+++ ${d.newPath}\n${d.newFile ? '(new file)' : ''}${d.deletedFile ? '(deleted)' : ''}\n${d.diff}` ) .join('\n\n'); @@ -171,19 +171,23 @@ export class ReviewerBrain { }; } catch (err) { logger.warn( - { rawResponse: rawResponse.slice(0, 2000), err: err instanceof Error ? err.message : String(err) }, + { + rawResponse: rawResponse.slice(0, 2000), + err: err instanceof Error ? err.message : String(err), + }, 'ReviewerBrain 评审响应解析失败' ); throw new Error('评审响应解析失败,请检查 LLM 输出格式'); } } - private buildReplyPrompt(input: ReviewerReplyInput, threadContext: ThreadContext, recalledContext: string): string { + private buildReplyPrompt( + input: ReviewerReplyInput, + threadContext: ThreadContext, + recalledContext: string + ): string { const findingsText = input.originalFindings - .map( - (f) => - `- [${f.severity}] \`${f.file}:${f.line}\` ${f.message}\n 建议:${f.suggestion}` - ) + .map(f => `- [${f.severity}] \`${f.file}:${f.line}\` ${f.message}\n 建议:${f.suggestion}`) .join('\n'); const notesText = formatThreadContext(threadContext); @@ -228,7 +232,10 @@ export class ReviewerBrain { }; } catch (err) { logger.warn( - { rawResponse: rawResponse.slice(0, 500), err: err instanceof Error ? err.message : String(err) }, + { + rawResponse: rawResponse.slice(0, 500), + err: err instanceof Error ? err.message : String(err), + }, 'ReviewerBrain 回复决策解析失败,保守不回复' ); return { shouldReply: false, replyBody: '', reason: 'LLM 回复解析失败,保守不回复' }; @@ -242,7 +249,7 @@ export class ReviewerBrain { private normalizeFindings(rawFindings: unknown[]): ReviewFinding[] { return rawFindings .filter((f): f is Record => typeof f === 'object' && f !== null) - .map((f) => ({ + .map(f => ({ severity: this.normalizeSeverity(String(f.severity ?? 'LOW')), file: String(f.file ?? 'unknown'), line: Number(f.line ?? 0), @@ -256,7 +263,9 @@ export class ReviewerBrain { private normalizeSeverity(severity: string): ReviewFinding['severity'] { const valid: ReviewFinding['severity'][] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; const upper = severity.toUpperCase(); - return valid.includes(upper as ReviewFinding['severity']) ? (upper as ReviewFinding['severity']) : 'LOW'; + return valid.includes(upper as ReviewFinding['severity']) + ? (upper as ReviewFinding['severity']) + : 'LOW'; } private extractAutoFixableIndices( @@ -264,11 +273,9 @@ export class ReviewerBrain { explicitIndices?: number[] ): number[] { if (explicitIndices && Array.isArray(explicitIndices)) { - return explicitIndices.filter((i) => i >= 0 && i < findings.length); + return explicitIndices.filter(i => i >= 0 && i < findings.length); } - return findings - .map((f, i) => (f.autoFixable ? i : -1)) - .filter((i) => i !== -1); + return findings.map((f, i) => (f.autoFixable ? i : -1)).filter(i => i !== -1); } } diff --git a/src/advance/classic/role-service-registry.ts b/src/advance/classic/role-service-registry.ts index e03647b..e70c11e 100755 --- a/src/advance/classic/role-service-registry.ts +++ b/src/advance/classic/role-service-registry.ts @@ -34,6 +34,7 @@ export class RoleServiceRegistry { */ async start(role: Role): Promise { await this.waitForMemoryMcpUrl(60000); + await this.waitForCodeGraphUrl(60000); await this.getService(role).start(); } @@ -50,6 +51,17 @@ export class RoleServiceRegistry { } } + /** 等待 Daemon 设置统一 CodeGraph Server URL。 */ + private async waitForCodeGraphUrl(timeoutMs = 60000): Promise { + const start = Date.now(); + while (!this.options.codeGraphUrl) { + if (Date.now() - start > timeoutMs) { + throw new Error('等待 CodeGraph Server URL 超时,无法启动角色服务'); + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + /** * 停止指定角色的服务 * @param role 角色标识 @@ -87,6 +99,14 @@ export class RoleServiceRegistry { } } + /** 设置 CodeGraph Server URL,并同步给已经懒创建的角色服务。 */ + setCodeGraphUrl(url: string): void { + this.options = { ...this.options, codeGraphUrl: url }; + for (const service of this.services.values()) { + service.setCodeGraphUrl(url); + } + } + /** * 获取已注册的服务实例 * @param role 角色标识 diff --git a/src/advance/classic/role-service.ts b/src/advance/classic/role-service.ts index e2e3ea3..67469e3 100755 --- a/src/advance/classic/role-service.ts +++ b/src/advance/classic/role-service.ts @@ -13,6 +13,8 @@ export interface RoleServiceOptions { submodulePath?: string; /** EverOS 数据目录 */ everosDataDir?: string; + /** Daemon 托管的 CodeGraph Server URL */ + codeGraphUrl?: string; } /** @@ -66,6 +68,7 @@ export class RoleService { ROLE: this.role, CK_DB_PATH: this.context.dbPath, CK_EVEROS_MCP_URL: this.mcpUrl ?? '', + CK_CODEGRAPH_SERVER_URL: this.options.codeGraphUrl ?? '', CK_LLM_API_KEY: this.context.getDaemonConfig?.().apiKey ?? '', CK_LLM_PROVIDER: this.context.getDaemonConfig?.().provider ?? 'anthropic', CK_LLM_MODEL: this.context.getDaemonConfig?.().model ?? '', @@ -100,6 +103,11 @@ export class RoleService { this.options.mcpUrl = url; } + /** 更新 CodeGraph Server URL,供重启后的角色子进程继续复用统一服务。 */ + setCodeGraphUrl(url: string): void { + this.options.codeGraphUrl = url; + } + /** * 停止角色服务 */ diff --git a/src/advance/classic/roles/archiver-manager.ts b/src/advance/classic/roles/archiver-manager.ts index 36be4df..2ee2ee4 100755 --- a/src/advance/classic/roles/archiver-manager.ts +++ b/src/advance/classic/roles/archiver-manager.ts @@ -1,4 +1,8 @@ import type { ArchiverConfig, Role } from '../../types.js'; +import { + createDefaultArchiverConfig, + normalizeArchiverConfig, +} from '../../archiver/provider-config.js'; import { BaseRoleManager } from './base-role-manager.js'; /** @@ -8,13 +12,19 @@ import { BaseRoleManager } from './base-role-manager.js'; export class ArchiverManager extends BaseRoleManager { readonly role: Role = 'archiver'; + async getConfig(projectId: string): Promise { + return normalizeArchiverConfig(await super.getConfig(projectId)); + } + + async updateConfig(projectId: string, config: ArchiverConfig): Promise { + if (config.role !== this.role) { + throw new Error(`Archiver 配置角色不匹配: ${config.role}`); + } + this.store.updateProjectRoleConfig(projectId, this.role, normalizeArchiverConfig(config)); + } + getDefaultConfig(): ArchiverConfig { - return { - role: 'archiver', - enabled: false, - reviewSchedule: '0 2 * * *', - learningEnabled: true, - }; + return createDefaultArchiverConfig(); } getSoulFileName(): string { diff --git a/src/advance/classic/runners/archiver-runner.ts b/src/advance/classic/runners/archiver-runner.ts index 3cdc7c6..46d5690 100755 --- a/src/advance/classic/runners/archiver-runner.ts +++ b/src/advance/classic/runners/archiver-runner.ts @@ -1,21 +1,60 @@ +import { existsSync, statSync } from 'node:fs'; import { readFile, readdir } from 'node:fs/promises'; import { createHash } from 'node:crypto'; -import { join, relative } from 'node:path'; +import { isAbsolute, join, relative, sep, type PlatformPath } from 'node:path'; import { BaseRoleRunner } from './base-role-runner.js'; import { ArchiverBrain, selectArchiverInputFiles } from '../archive/archiver-brain.js'; import { ArchiverActor } from '../archive/archiver-actor.js'; import { MemoryClient } from '../memory/memory-client.js'; import type { Project, RoleConfig } from '../../types.js'; import type { LlmClient } from '../../llm/client.js'; -import { getArchiveRoot } from '../../types.js'; +import { getArchiveRoot, isRoleConfigEnabled } from '../../types.js'; +import { + normalizeArchiverConfig, +} from '../../archiver/provider-config.js'; +import { + type ArchiverProviderCoordinator, + type ArchiverProviderExecution, +} from '../../archiver/provider-orchestrator.js'; +import { createArchiverProviderCoordinator } from '../../archiver/codegraph-client.js'; import { loadState, saveState, type MrAgentState } from './shared/state-utils.js'; -import type { ProjectKnowledgeItem } from '../memory/types.js'; +import { buildEverOSAgentId, type ProjectKnowledgeItem } from '../memory/types.js'; + +const ARCHIVER_SCAN_EXCLUDED_DIRECTORIES = new Set([ + 'node_modules', + '.git', + '.codekeeper', + 'dist', + '.repowise', + '.codebase-memory', + 'graphify-out', +]); +const ARCHIVER_SCAN_EXCLUDED_FILES = new Set(['.graphify_detect.json']); export interface ArchiverRunnerOptions { /** LLM 客户端实例 */ llmClient: LlmClient; /** MCP Server URL */ mcpUrl: string; + /** Provider 编排器,默认使用系统注册表 */ + providerOrchestrator?: ArchiverProviderCoordinator; + /** Daemon 托管的 CodeGraph Server URL */ + codeGraphUrl?: string; +} + +export function buildArchiverMemoryContext( + projectId: string, + archiverName: string, + sessionId: string +) { + return { + appId: 'codekeeper-advance', + projectId, + agentId: buildEverOSAgentId('archiver', archiverName), + agentDisplayName: archiverName, + userId: 'codekeeper-system', + sessionId, + }; } /** @@ -39,27 +78,35 @@ export function buildArchiverSourceFingerprint( sourceFiles: string[], fileContents: Record ): string { - const canonical = [...new Set(sourceFiles)] - .sort() - .map(file => ({ - file, - contentHash: createHash('sha256') - .update(fileContents[file] ?? '') - .digest('hex'), - })); + const canonical = [...new Set(sourceFiles)].sort().map(file => ({ + file, + contentHash: createHash('sha256') + .update(fileContents[file] ?? '') + .digest('hex'), + })); return createHash('sha256').update(JSON.stringify(canonical)).digest('hex'); } +/** 判断目录项是否属于依赖、归档或 Provider 生成物。 */ +export function isArchiverScanEntryExcluded(entryName: string, isDirectory: boolean): boolean { + return (isDirectory ? ARCHIVER_SCAN_EXCLUDED_DIRECTORIES : ARCHIVER_SCAN_EXCLUDED_FILES).has( + entryName + ); +} + /** * Archiver 角色的 Runner 实现 * 负责扫描项目文件、提炼知识、写入 EverOS */ export class ArchiverRunner extends BaseRoleRunner { private readonly mcpUrl: string; + private readonly providerOrchestrator: ArchiverProviderCoordinator; constructor(options: ArchiverRunnerOptions) { super({ llmClient: options.llmClient }); this.mcpUrl = options.mcpUrl; + this.providerOrchestrator = + options.providerOrchestrator ?? createArchiverProviderCoordinator(options.codeGraphUrl); } protected getRole(): 'archiver' { @@ -75,48 +122,96 @@ export class ArchiverRunner extends BaseRoleRunner { */ protected validatePrerequisites(project: Project): boolean { const config = this.getRoleConfig(project); - if (!config?.enabled) { + if (!isRoleConfigEnabled(config)) { console.log(`[ArchiverRunner] 项目 ${project.name} 未启用,跳过`); return false; } - // 父类要求 gitlab,但 archiver 仅依赖本地目录 + try { + if (!existsSync(project.rootPath) || !statSync(project.rootPath).isDirectory()) { + console.warn(`[ArchiverRunner] 项目 ${project.name} 的本地目录不存在,跳过`); + return false; + } + } catch { + console.warn(`[ArchiverRunner] 项目 ${project.name} 的本地目录不可访问,跳过`); + return false; + } + return true; } - protected async runProject(project: Project, _config: RoleConfig): Promise { + protected async runProject(project: Project, config: RoleConfig): Promise { console.log(`[ArchiverRunner] 扫描项目 ${project.name}`); const archiveRoot = getArchiveRoot(project); + const archiverConfig = normalizeArchiverConfig(config); + const providerExecution = await this.syncProviders(project, archiveRoot); + if (!providerExecution.shouldRunBuiltin) { + if (!providerExecution.report.selectedPrimary) { + const details = providerExecution.report.statuses + .filter(status => status.placement !== 'enricher') + .map(status => `${status.providerId}: ${status.message ?? status.state}`) + .join(';'); + throw new Error( + `Archiver 未找到可用的主 Provider,且内置安全回退已关闭${details ? `:${details}` : ''}` + ); + } + console.log( + `[ArchiverRunner] Provider ${providerExecution.report.selectedPrimary} 已完成,本轮无需内置提炼` + ); + return; + } + + let builtinFinalized = false; + const finalizeBuiltin = async (success: boolean, message: string) => { + if (builtinFinalized) return; + builtinFinalized = true; + await this.providerOrchestrator + .finalizeBuiltin(project, archiveRoot, providerExecution.report, success, message) + .catch(error => { + const detail = error instanceof Error ? error.message : String(error); + console.warn(`[ArchiverRunner] Provider 状态写入失败: ${detail}`); + }); + }; + const failBuiltin = async (message: string, error?: unknown) => { + await finalizeBuiltin(false, message); + if (providerExecution.builtinRequired) { + throw error instanceof Error ? error : new Error(message); + } + console.warn(`[ArchiverRunner] 内置增强阶段失败,不影响已完成的主 Provider: ${message}`); + }; + const files = await this.listProjectFiles(project.rootPath, archiveRoot); if (files.length === 0) { console.log(`[ArchiverRunner] 项目 ${project.name} 无文件,跳过`); + await finalizeBuiltin(true, '项目无可分析文件,内置阶段已跳过'); return; } const state = loadState(project); + const archiverName = archiverConfig.archiverName.trim() || 'CodeKeeper Archiver'; const memoryClient = new MemoryClient({ mcpUrl: this.mcpUrl, - context: { - appId: 'codekeeper-advance', - projectId: project.id, - agentId: 'archiver', - userId: 'codekeeper-system', - sessionId: buildArchiverSessionId(project.id, new Date()), - }, + context: buildArchiverMemoryContext( + project.id, + archiverName, + buildArchiverSessionId(project.id, new Date()) + ), }); try { await memoryClient.connect(); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`[ArchiverRunner] MemoryClient 连接失败,本轮不标记完成: ${message}`); - throw error; + await failBuiltin(`内置知识提炼失败:${message}`, error); + return; } const brain = new ArchiverBrain({ llmClient: this.llmClient }); const actor = new ArchiverActor({ memoryClient }); const sourceFiles = selectArchiverInputFiles(files); - const sourceFingerprint = await this.buildSourceFingerprint(project.rootPath, sourceFiles); + const fileContents = await this.readSourceFiles(project.rootPath, sourceFiles); + const sourceFingerprint = buildArchiverSourceFingerprint(sourceFiles, fileContents); state.archiverState ??= { sourceFingerprint: '', items: {}, @@ -127,15 +222,17 @@ export class ArchiverRunner extends BaseRoleRunner { const pendingRecovered = await this.retryPendingKnowledge(actor, state, project); if (!pendingRecovered) { console.warn('[ArchiverRunner] 仍有项目知识等待补偿,本轮不重复调用 LLM'); + await failBuiltin('仍有项目知识等待补偿写入'); return; } if (state.archiverState.sourceFingerprint === sourceFingerprint) { console.log('[ArchiverRunner] 项目知识输入未变化,跳过 LLM 分析'); + await finalizeBuiltin(true, '项目知识输入未变化,无需重复提炼'); return; } - const items = await brain.analyzeProject(project, sourceFiles); + const items = await brain.analyzeProject(project, sourceFiles, fileContents); const stableItems = items.map(item => this.stabilizeKnowledgeItem(project.id, item)); console.log(`[ArchiverRunner] 提炼出 ${stableItems.length} 条项目知识`); @@ -190,12 +287,53 @@ export class ArchiverRunner extends BaseRoleRunner { state.archiverState.sourceFingerprint = sourceFingerprint; state.archiverState.updatedAt = Date.now(); saveState(project, state, 'archiver'); + await finalizeBuiltin(true, `内置知识提炼完成,共处理 ${stableItems.length} 条知识`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await failBuiltin(`内置知识提炼失败:${message}`, error); } finally { await memoryClient.disconnect().catch(() => undefined); } } - private async listProjectFiles(rootPath: string, archiveRoot: string, subPath = ''): Promise { + private async syncProviders( + project: Project, + archiveRoot: string + ): Promise { + try { + return await this.providerOrchestrator.syncProject(project, archiveRoot); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[ArchiverRunner] Provider 编排失败,回退到内置知识提炼: ${message}`); + const now = Date.now(); + return { + shouldRunBuiltin: true, + builtinRequired: true, + report: { + schemaVersion: 1, + projectId: project.id, + generatedAt: now, + selectedPrimary: 'builtin', + statuses: [ + { + providerId: 'builtin', + placement: 'fallback', + state: 'deferred', + startedAt: now, + finishedAt: now, + message: `Provider 编排失败,等待内置阶段:${message}`, + }, + ], + }, + }; + } + } + + private async listProjectFiles( + rootPath: string, + archiveRoot: string, + subPath = '' + ): Promise { const dir = join(rootPath, subPath); const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); const files: string[] = []; @@ -203,25 +341,29 @@ export class ArchiverRunner extends BaseRoleRunner { const relativePath = subPath ? `${subPath}/${entry.name}` : entry.name; if (entry.isDirectory()) { const absolutePath = join(rootPath, relativePath); - if (['node_modules', '.git', '.codekeeper', 'dist'].includes(entry.name)) continue; + if (isArchiverScanEntryExcluded(entry.name, true)) continue; // 跳过归档目录及其子目录,避免归档输出被反复扫描 - if (!relative(archiveRoot, absolutePath).startsWith('..')) continue; + if (isPathInside(archiveRoot, absolutePath)) continue; files.push(...(await this.listProjectFiles(rootPath, archiveRoot, relativePath))); } else if (entry.isFile()) { + if (isArchiverScanEntryExcluded(entry.name, false)) continue; files.push(relativePath); } } return files; } - private async buildSourceFingerprint(rootPath: string, sourceFiles: string[]): Promise { + private async readSourceFiles( + rootPath: string, + sourceFiles: string[] + ): Promise> { const fileContents: Record = {}; await Promise.all( sourceFiles.map(async file => { fileContents[file] = await readFile(join(rootPath, file), 'utf8').catch(() => undefined); }) ); - return buildArchiverSourceFingerprint(sourceFiles, fileContents); + return fileContents; } private async retryPendingKnowledge( @@ -268,7 +410,10 @@ export class ArchiverRunner extends BaseRoleRunner { } } - private stabilizeKnowledgeItem(projectId: string, item: ProjectKnowledgeItem): ProjectKnowledgeItem { + private stabilizeKnowledgeItem( + projectId: string, + item: ProjectKnowledgeItem + ): ProjectKnowledgeItem { const canonical = JSON.stringify({ sourceId: item.id, category: item.category, @@ -293,3 +438,24 @@ export class ArchiverRunner extends BaseRoleRunner { return createHash('sha256').update(value).digest('hex'); } } + +const CURRENT_PLATFORM_PATH: Pick = { + relative, + isAbsolute, + sep, +}; + +/** 判断候选路径是否位于父目录内,兼容 Windows 跨盘符路径。 */ +export function isPathInside( + parentPath: string, + candidatePath: string, + pathApi: Pick = CURRENT_PLATFORM_PATH +): boolean { + const relativePath = pathApi.relative(parentPath, candidatePath); + return ( + relativePath === '' || + (!pathApi.isAbsolute(relativePath) && + relativePath !== '..' && + !relativePath.startsWith(`..${pathApi.sep}`)) + ); +} diff --git a/src/advance/classic/runners/base-role-runner.ts b/src/advance/classic/runners/base-role-runner.ts index 35cef3b..3f5ec30 100755 --- a/src/advance/classic/runners/base-role-runner.ts +++ b/src/advance/classic/runners/base-role-runner.ts @@ -2,7 +2,11 @@ import { schedule, validate as validateCron } from 'node-cron'; import { existsSync } from 'node:fs'; import { LlmClient } from '../../llm/client.js'; import type { Project, GitlabConfig, RoleConfig, Role } from '../../types.js'; -import { getArchiveRoot } from '../../types.js'; +import { + getArchiveRoot, + getRoleConfigSchedule, + isRoleConfigEnabled, +} from '../../types.js'; import { loadSoulContent, type SoulContent } from '../soul/soul-loader.js'; import { loadProjectContext } from '../context/project-context-loader.js'; import { @@ -56,12 +60,12 @@ export abstract class BaseRoleRunner implements IRoleRunner { const fullProject = project as unknown as Project; const config = this.getRoleConfig(fullProject); - if (!config?.enabled) { + if (!isRoleConfigEnabled(config)) { console.log(`[${this.getRoleName()}] 项目 ${fullProject.name} 未启用,跳过`); return; } - const scheduleExpr = config.reviewSchedule?.trim() || this.getDefaultSchedule(); + const scheduleExpr = getRoleConfigSchedule(config)?.trim() || this.getDefaultSchedule(); if (!validateCron(scheduleExpr)) { const message = `[${this.getRoleName()}] 项目 ${fullProject.name} 的 reviewSchedule "${scheduleExpr}" 不是合法的 cron 表达式`; console.error(message); @@ -121,7 +125,7 @@ export abstract class BaseRoleRunner implements IRoleRunner { } const config = this.getRoleConfig(project); - if (!config?.enabled) { + if (!config || !isRoleConfigEnabled(config)) { console.log(`[${this.getRoleName()}] 项目 ${project.name} 未启用,跳过`); return; } @@ -166,7 +170,7 @@ export abstract class BaseRoleRunner implements IRoleRunner { } const config = this.getRoleConfig(project); - if (!config?.enabled) { + if (!isRoleConfigEnabled(config)) { console.log(`[${this.getRoleName()}] 项目 ${project.name} 未启用,跳过`); return false; } diff --git a/src/advance/classic/runners/maintainer-runner.ts b/src/advance/classic/runners/maintainer-runner.ts index c5baba4..6716497 100755 --- a/src/advance/classic/runners/maintainer-runner.ts +++ b/src/advance/classic/runners/maintainer-runner.ts @@ -87,6 +87,10 @@ import { MAX_DISCUSSION_REPLY_CHARS, } from './shared/reply-safety.js'; import { defaultPromptLoader } from '../../llm/prompts/loader.js'; +import { + ArchiverProjectKnowledgeSource, + mergeProjectKnowledgeContext, +} from '../../archiver/project-knowledge-source.js'; function logMemoryUsage(label: string): void { const usage = process.memoryUsage(); @@ -526,7 +530,13 @@ export class MaintainerRunner extends BaseRoleRunner { const provider = new GitLabProvider(gitlabConfig); - const { soul, projectContext } = this.loadRoleContext(project); + const { soul, projectContext: legacyProjectContext } = this.loadRoleContext(project); + const projectKnowledgeSource = new ArchiverProjectKnowledgeSource({ project }); + const [providerContext, providerKnowledgeAvailable] = await Promise.all([ + projectKnowledgeSource.loadContext(6000).catch(() => ''), + projectKnowledgeSource.isAvailable().catch(() => false), + ]); + const projectContext = mergeProjectKnowledgeContext(legacyProjectContext, providerContext); const mcpUrl = process.env.CK_EVEROS_MCP_URL ?? ''; const baseMemoryContext = { @@ -573,7 +583,7 @@ export class MaintainerRunner extends BaseRoleRunner { let mrs: MergeRequest[]; try { - mrs = await provider.listOpenMRs(config.filter); + mrs = await provider.listOpenMRs(maintainerConfig.filter); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`[MaintainerRunner] 列出项目 ${project.name} 的 MR 失败: ${message}`); @@ -649,9 +659,16 @@ export class MaintainerRunner extends BaseRoleRunner { }) : undefined; await memoryClient?.connect().catch(() => undefined); - const recallPlanner = memoryClient - ? new RecallPlanner({ llmClient: this.llmClient, memoryClient }) - : undefined; + const recallPlanner = + memoryClient || providerKnowledgeAvailable + ? new RecallPlanner({ + llmClient: this.llmClient, + memoryClient, + projectKnowledgeSource: providerKnowledgeAvailable + ? projectKnowledgeSource + : undefined, + }) + : undefined; const brain = new MaintainerBrain({ ...brainOptions, memoryClient, recallPlanner }); const actor = new MaintainerActor({ provider, diff --git a/src/advance/classic/runners/reviewer-runner.ts b/src/advance/classic/runners/reviewer-runner.ts index 6ef0c05..a926804 100755 --- a/src/advance/classic/runners/reviewer-runner.ts +++ b/src/advance/classic/runners/reviewer-runner.ts @@ -13,7 +13,12 @@ import { MemoryClient } from '../memory/memory-client.js'; import { RecallPlanner } from '../memory/recall-planner.js'; import { buildEverOSAgentId } from '../memory/types.js'; import { buildFindingCaseKey } from '../memory/finding-case-key.js'; -import { loadState, saveState, getDiscussionStateKey, type MrAgentState } from './shared/state-utils.js'; +import { + loadState, + saveState, + getDiscussionStateKey, + type MrAgentState, +} from './shared/state-utils.js'; import { deliverReviewComment, isReviewCommentDeliveryPending, @@ -29,10 +34,20 @@ import { REVIEWER_ROLE_LABEL, } from './shared/review-utils.js'; import type { Project, RoleConfig, ReviewerConfig } from '../../types.js'; -import type { MergeRequest, MrDiff, ReviewFinding, Discussion, ReviewerComment } from '../provider/types.js'; +import type { + MergeRequest, + MrDiff, + ReviewFinding, + Discussion, + ReviewerComment, +} from '../provider/types.js'; import { BaseRoleRunner } from './base-role-runner.js'; import { logger } from '../../../core/logger.js'; import { getCommentActivityAt } from '../provider/activity-window.js'; +import { + ArchiverProjectKnowledgeSource, + mergeProjectKnowledgeContext, +} from '../../archiver/project-knowledge-source.js'; /** * 构建 Reviewer 会话 ID(按 MR 粒度) @@ -73,7 +88,13 @@ export class ReviewerRunner extends BaseRoleRunner { const provider = new GitLabProvider(project.gitlab); const state = loadState(project); - const { soul, projectContext } = this.loadRoleContext(project); + const { soul, projectContext: legacyProjectContext } = this.loadRoleContext(project); + const projectKnowledgeSource = new ArchiverProjectKnowledgeSource({ project }); + const [providerContext, providerKnowledgeAvailable] = await Promise.all([ + projectKnowledgeSource.loadContext(6000).catch(() => ''), + projectKnowledgeSource.isAvailable().catch(() => false), + ]); + const projectContext = mergeProjectKnowledgeContext(legacyProjectContext, providerContext); const reviewerConfig = config as ReviewerConfig; const reviewerName = reviewerConfig.reviewerName ?? 'CodeKeeper Reviewer'; @@ -103,11 +124,13 @@ export class ReviewerRunner extends BaseRoleRunner { }); console.log(`[ReviewerRunner] 扫描项目 ${project.name} 的 open MRs...`); - console.log(`[ReviewerRunner] 项目 ${project.name} 使用 filter: ${JSON.stringify(config.filter ?? {})}`); + console.log( + `[ReviewerRunner] 项目 ${project.name} 使用 filter: ${JSON.stringify(reviewerConfig.filter ?? {})}` + ); let mrs: MergeRequest[]; try { - mrs = await provider.listOpenMRs(config.filter); + mrs = await provider.listOpenMRs(reviewerConfig.filter); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`[ReviewerRunner] 列出项目 ${project.name} 的 MR 失败: ${message}`); @@ -117,7 +140,7 @@ export class ReviewerRunner extends BaseRoleRunner { console.log(`[ReviewerRunner] 项目 ${project.name} 发现 ${mrs.length} 个 open MR`); // 默认跳过 draft;如果 filter 里显式配置了 Draft=true,则保留 draft MR - const draftCondition = config.filter?.conditions.find((c) => c.field === 'draft'); + const draftCondition = reviewerConfig.filter?.conditions.find(c => c.field === 'draft'); const includeDraft = draftCondition?.values.includes('true') ?? false; for (const mr of mrs) { @@ -173,9 +196,16 @@ export class ReviewerRunner extends BaseRoleRunner { if (memoryClient) { await this.retryPendingReviewMemory(memoryClient, project, state, stateKey); } - const recallPlanner = memoryClient - ? new RecallPlanner({ llmClient: this.llmClient, memoryClient }) - : undefined; + const recallPlanner = + memoryClient || providerKnowledgeAvailable + ? new RecallPlanner({ + llmClient: this.llmClient, + memoryClient, + projectKnowledgeSource: providerKnowledgeAvailable + ? projectKnowledgeSource + : undefined, + }) + : undefined; const brain = new ReviewerBrain({ ...brainOptions, memoryClient, recallPlanner }); let result; @@ -189,7 +219,7 @@ export class ReviewerRunner extends BaseRoleRunner { } const findingsHash = computeFindingsHash(result.findings); - const findingsKeys = result.findings.map((f) => getFindingKey(f)); + const findingsKeys = result.findings.map(f => getFindingKey(f)); const headSha = shaInfo?.headSha ?? ''; const mrUpdatedAt = new Date(mr.updatedAt).getTime(); @@ -236,13 +266,10 @@ export class ReviewerRunner extends BaseRoleRunner { const threadRiskLevels = new Set( reviewerConfig.threadRiskLevels ?? ['CRITICAL', 'HIGH'] ); - const postedFindingKeys = new Set( - (state.discussions[stateKey] ?? []).map(d => d.findingKey) - ); + const postedFindingKeys = new Set((state.discussions[stateKey] ?? []).map(d => d.findingKey)); const missingThreadFindings = result.findings.filter( finding => - threadRiskLevels.has(finding.severity) && - !postedFindingKeys.has(getFindingKey(finding)) + threadRiskLevels.has(finding.severity) && !postedFindingKeys.has(getFindingKey(finding)) ); const reviewDelivery = state.reviewCommentDelivery?.[stateKey]; const hasPendingReviewDelivery = @@ -272,7 +299,7 @@ export class ReviewerRunner extends BaseRoleRunner { const newFindings = previousReview ? result.findings.filter( - (f) => + f => !previousReview.findingsKeys.includes(getFindingKey(f)) && !postedFindingKeys.has(getFindingKey(f)) ) @@ -282,10 +309,17 @@ export class ReviewerRunner extends BaseRoleRunner { let newFindingsToPost = newFindings; if (memoryClient) { try { - newFindingsToPost = await this.filterDuplicateCases(memoryClient, project.id, mr.iid, newFindings); + newFindingsToPost = await this.filterDuplicateCases( + memoryClient, + project.id, + mr.iid, + newFindings + ); const skipped = newFindings.length - newFindingsToPost.length; if (skipped > 0) { - console.log(`[ReviewerRunner] MR !${mr.iid} 从新增发现项中过滤 ${skipped} 个 EverOS 已存在的 case`); + console.log( + `[ReviewerRunner] MR !${mr.iid} 从新增发现项中过滤 ${skipped} 个 EverOS 已存在的 case` + ); } } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -301,7 +335,7 @@ export class ReviewerRunner extends BaseRoleRunner { const lastAppendStillExists = recoveredDeliveries.appendNoteId !== undefined || (previousReview?.lastAppendNoteId !== undefined && - allComments.some((comment) => comment.id === previousReview.lastAppendNoteId)); + allComments.some(comment => comment.id === previousReview.lastAppendNoteId)); const shouldAppend = !shouldPostSummary && newFindingsToPost.length > 0 && @@ -349,7 +383,6 @@ export class ReviewerRunner extends BaseRoleRunner { state, }); } - } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`[ReviewerRunner] MR !${mr.iid} 发布评论失败: ${message}`); @@ -437,7 +470,10 @@ export class ReviewerRunner extends BaseRoleRunner { discussionId: posted?.discussionId, }; }); - const casesKey = currentCases.map(item => item.key).sort().join('|'); + const casesKey = currentCases + .map(item => item.key) + .sort() + .join('|'); if ( !memoryState.findingCases || memoryState.findingCases.key !== casesKey || @@ -477,7 +513,9 @@ export class ReviewerRunner extends BaseRoleRunner { pendingCases.status = 'recorded'; pendingCases.lastError = undefined; pendingCases.updatedAt = Date.now(); - console.log(`[ReviewerRunner] MR !${mr.iid} 已记录 ${pendingCases.cases.length} 个 finding case 到 EverOS`); + console.log( + `[ReviewerRunner] MR !${mr.iid} 已记录 ${pendingCases.cases.length} 个 finding case 到 EverOS` + ); } catch (err) { const message = err instanceof Error ? err.message : String(err); pendingCases.status = 'failed'; @@ -589,7 +627,7 @@ export class ReviewerRunner extends BaseRoleRunner { project?: Project ): Promise { const reviewerThreads = discussions.filter( - (d) => + d => d.notes.length > 0 && isAgentAuthoredNote(d.notes[0].body) && d.notes[0].body.includes(REVIEWER_ROLE_LABEL) @@ -614,15 +652,15 @@ export class ReviewerRunner extends BaseRoleRunner { const delivery = threadState.delivery; const deliveryNoteExists = Boolean( delivery && - discussion.notes.some( - note => note.id === delivery.replyNoteId || note.body === delivery.replyBody - ) + discussion.notes.some( + note => note.id === delivery.replyNoteId || note.body === delivery.replyBody + ) ); const shouldReconcileDelivery = Boolean( delivery && - (threadState.pendingTargetNoteId !== undefined || - isDiscussionDeliveryPending(delivery) || - (delivery.replyStatus === 'posted' && !deliveryNoteExists)) + (threadState.pendingTargetNoteId !== undefined || + isDiscussionDeliveryPending(delivery) || + (delivery.replyStatus === 'posted' && !deliveryNoteExists)) ); if (shouldReconcileDelivery && delivery) { const pendingResult = await deliverDiscussionReply({ @@ -664,7 +702,7 @@ export class ReviewerRunner extends BaseRoleRunner { const lastRepliedAt = threadState.lastRepliedAt; const targetNotes = discussion.notes - .filter((note) => { + .filter(note => { const ts = getCommentActivityAt(note); return ( !Number.isNaN(ts) && @@ -684,7 +722,7 @@ export class ReviewerRunner extends BaseRoleRunner { `[ReviewerRunner] discussion ${discussion.id} 有 ${targetNotes.length} 条新的人类回复待处理` ); - const threadNotes = discussion.notes.map((n) => ({ + const threadNotes = discussion.notes.map(n => ({ author: n.author, body: n.body, createdAt: n.createdAt, @@ -705,7 +743,9 @@ export class ReviewerRunner extends BaseRoleRunner { }); if (!decision.shouldReply || !decision.replyBody?.trim()) { - console.log(`[ReviewerRunner] discussion ${discussion.id} 的回复判断: ${decision.reason}`); + console.log( + `[ReviewerRunner] discussion ${discussion.id} 的回复判断: ${decision.reason}` + ); threadState.lastRepliedAt = Math.max(threadState.lastRepliedAt, noteTs); if (project) saveState(project, state, 'reviewer'); continue; @@ -726,7 +766,7 @@ export class ReviewerRunner extends BaseRoleRunner { threadState.delivery = delivery; }, checkpoint: () => { - if (project) saveState(project, state, 'reviewer'); + if (project) saveState(project, state, 'reviewer'); }, }); if (replyResult.pending) { @@ -805,7 +845,7 @@ export class ReviewerRunner extends BaseRoleRunner { if (findings.length === 0) return findings; const checks = await Promise.all( - findings.map(async (f) => { + findings.map(async f => { const key = buildFindingCaseKey({ projectId, mrIid, @@ -815,7 +855,7 @@ export class ReviewerRunner extends BaseRoleRunner { }); try { const items = await memoryClient.recallFindingCase(key); - const exists = items.some((item) => item.includes(`[CASE:${key}]`)); + const exists = items.some(item => item.includes(`[CASE:${key}]`)); return { finding: f, exists }; } catch { return { finding: f, exists: false }; @@ -823,7 +863,7 @@ export class ReviewerRunner extends BaseRoleRunner { }) ); - return checks.filter((c) => !c.exists).map((c) => c.finding); + return checks.filter(c => !c.exists).map(c => c.finding); } private async retryPendingReviewMemory( @@ -886,7 +926,7 @@ function computeFindingsHash(findings: ReviewFinding[]): string { if (a.file !== b.file) return a.file.localeCompare(b.file); return a.line - b.line; }) - .map((f) => ({ + .map(f => ({ severity: f.severity, file: f.file, line: f.line, diff --git a/src/advance/classic/runners/role-runner.ts b/src/advance/classic/runners/role-runner.ts index 2fdb133..9420078 100755 --- a/src/advance/classic/runners/role-runner.ts +++ b/src/advance/classic/runners/role-runner.ts @@ -35,6 +35,7 @@ export interface IRoleRunner { export interface CreateRoleRunnerOptions { llmClient: LlmClient; mcpUrl?: string; + codeGraphUrl?: string; } /** @@ -51,7 +52,11 @@ export function createRoleRunner(role: Role, options: CreateRoleRunnerOptions): case 'maintainer': return new MaintainerRunner({ llmClient: options.llmClient }); case 'archiver': - return new ArchiverRunner({ llmClient: options.llmClient, mcpUrl: options.mcpUrl ?? process.env.CK_EVEROS_MCP_URL ?? '' }); + return new ArchiverRunner({ + llmClient: options.llmClient, + mcpUrl: options.mcpUrl ?? process.env.CK_EVEROS_MCP_URL ?? '', + codeGraphUrl: options.codeGraphUrl ?? process.env.CK_CODEGRAPH_SERVER_URL, + }); default: throw new Error(`未支持的角色: ${role}`); } diff --git a/src/advance/config/project-config.ts b/src/advance/config/project-config.ts index 158fe56..c83ba2e 100755 --- a/src/advance/config/project-config.ts +++ b/src/advance/config/project-config.ts @@ -1,8 +1,26 @@ import { readFileSync } from 'node:fs'; import { join, normalize, relative, resolve } from 'node:path'; +import { minimatch } from 'minimatch'; import { z } from 'zod'; import YAML from 'yaml'; +/** 无论项目配置如何都必须排除的依赖与版本控制目录。 */ +export const SYSTEM_PROJECT_EXCLUDE_PATTERNS = ['**/node_modules/**', '**/.git/**']; + +const DEFAULT_PROJECT_EXCLUDE_PATTERNS = [ + ...SYSTEM_PROJECT_EXCLUDE_PATTERNS, + '**/dist/**', + '**/release/*-unpacked/**', + '**/.codekeeper/drafts/**', + '**/*.tmp', + '**/*.tmp.*', + '**/*~', + '**/.DS_Store', + '**/Thumbs.db', + '**/*.swp', + '**/*.bak', +]; + /** * 项目级 .codekeeper/config.yaml 的校验 schema */ @@ -24,13 +42,31 @@ export const projectConfigSchema = z.object({ '**/*.xml', '**/*.svg', ]), - exclude: z.array(z.string()).default(['node_modules/**', '.git/**', 'dist/**', '.codekeeper/drafts/**', '**/*.tmp', '**/*.tmp.*', '**/*~', '**/.DS_Store', '**/Thumbs.db', '**/*.swp', '**/*.bak']), + exclude: z.array(z.string()).default(DEFAULT_PROJECT_EXCLUDE_PATTERNS), categories: z.array(z.string()).default([]), docTypes: z.array(z.string()).default([]), }); export type ProjectConfig = z.infer; +/** + * 使用统一语义匹配项目相对路径。 + * + * 同时尝试目录尾斜杠形式,确保 globstar 目录规则能在遍历进入目录前命中。 + */ +export function matchesProjectPathPatterns(filePath: string, patterns: string[]): boolean { + const normalizedPath = filePath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''); + if (!normalizedPath) return false; + const directoryPath = `${normalizedPath}/`; + return patterns.some(rawPattern => { + const pattern = rawPattern.replace(/\\/g, '/'); + return ( + minimatch(normalizedPath, pattern, { dot: true }) || + minimatch(directoryPath, pattern, { dot: true }) + ); + }); +} + /** * 计算归档目录相对于项目根的排除模式 */ @@ -53,7 +89,9 @@ function computeArchiveExcludePattern(projectRoot: string, archiveRoot: string): * @param archiveRoot 归档位置;未提供时使用 projectRoot/.codekeeper */ export function loadProjectConfig(projectRoot: string, archiveRoot?: string): ProjectConfig { - const configDir = archiveRoot ? normalize(resolve(archiveRoot)) : join(projectRoot, '.codekeeper'); + const configDir = archiveRoot + ? normalize(resolve(archiveRoot)) + : join(projectRoot, '.codekeeper'); const configPath = join(configDir, 'config.yaml'); let config: ProjectConfig; try { @@ -75,5 +113,11 @@ export function loadProjectConfig(projectRoot: string, archiveRoot?: string): Pr config.exclude.push(archivePattern); } + for (const pattern of SYSTEM_PROJECT_EXCLUDE_PATTERNS) { + if (!config.exclude.includes(pattern)) { + config.exclude.push(pattern); + } + } + return config; } diff --git a/src/advance/daemon.ts b/src/advance/daemon.ts index 5376f90..bcdda51 100755 --- a/src/advance/daemon.ts +++ b/src/advance/daemon.ts @@ -21,11 +21,18 @@ import { MemoryWriteQueue } from './classic/memory/memory-write-queue.js'; import { MemoryWriteRetryService } from './classic/memory/memory-write-retry-service.js'; import { LocalModelServiceManager } from './classic/memory/local-model-service.js'; import { DEFAULT_EMBEDDING_MODEL, DEFAULT_RERANK_MODEL, getEmbeddingModelDimension, fetchEmbeddingModelDimension } from './classic/memory/local-model-catalog.js'; -import { RemoteModelChecker, cleanBaseUrl } from './classic/memory/remote-model-checker.js'; -import type { EverosStatus } from '../electron/shared/service-status.js'; -import type { RemoteModelStatus } from '../electron/shared/service-status.js'; -import path from 'node:path'; -import { join } from 'node:path'; +import { RemoteModelChecker, cleanBaseUrl } from './classic/memory/remote-model-checker.js'; +import type { + CodeGraphServiceStatus, + EverosStatus, +} from '../electron/shared/service-status.js'; +import type { RemoteModelStatus } from '../electron/shared/service-status.js'; +import path from 'node:path'; +import { join } from 'node:path'; +import { + CodeGraphService, + type CodeGraphServiceController, +} from './archiver/codegraph-service.js'; export interface DaemonOptions { registry: ProjectRegistry; @@ -52,9 +59,11 @@ export interface DaemonOptions { embeddingModel?: string; /** 本地 Rerank 模型名;默认使用 DEFAULT_RERANK_MODEL */ rerankModel?: string; - /** EverOS 独立配置;未设置时继承 Agent 通用配置 */ - everos?: import('./config/daemon-config.js').EverOSConfig; -} + /** EverOS 独立配置;未设置时继承 Agent 通用配置 */ + everos?: import('./config/daemon-config.js').EverOSConfig; + /** 测试或嵌入场景可替换 CodeGraph 服务实现 */ + codeGraphService?: CodeGraphServiceController; +} export class Daemon { private watchers = new Map(); @@ -73,8 +82,9 @@ export class Daemon { private everosState: EverosStatus['state'] = 'idle'; private everosHttpUrl: string | null = null; private everosError: string | null = null; - private localModelManager: LocalModelServiceManager; - private memoryWriteQueue: MemoryWriteQueue; + private localModelManager: LocalModelServiceManager; + private codeGraphService: CodeGraphServiceController; + private memoryWriteQueue: MemoryWriteQueue; private memoryWriteRetryService: MemoryWriteRetryService; private embeddingDim: number | null = null; private remoteModelChecker = new RemoteModelChecker(); @@ -110,10 +120,12 @@ export class Daemon { maxEventsPerScan: this.options.maxEventsPerScan, }); - this.localModelManager = new LocalModelServiceManager({ - embeddingModel: this.options.embeddingModel, - rerankModel: this.options.rerankModel, - }); + this.localModelManager = new LocalModelServiceManager({ + embeddingModel: this.options.embeddingModel, + rerankModel: this.options.rerankModel, + }); + this.codeGraphService = + this.options.codeGraphService ?? new CodeGraphService({ registry: this.options.registry }); this.memoryWriteQueue = new MemoryWriteQueue({ store: options.store }); this.memoryWriteRetryService = new MemoryWriteRetryService({ @@ -127,8 +139,9 @@ export class Daemon { serviceRegistry: this.serviceRegistry, dbPath: options.dbPath, scanService: this.scanService, - localModelManager: this.localModelManager, - remoteModelChecker: this.remoteModelChecker, + localModelManager: this.localModelManager, + codeGraphService: this.codeGraphService, + remoteModelChecker: this.remoteModelChecker, getRemoteModelStatus: () => this.remoteModelStatus, getProvider: (project) => { if (project.gitlab) { @@ -161,7 +174,8 @@ export class Daemon { everos: this.options.everos ? JSON.stringify(this.options.everos) : '', }), isDaemonRunning: () => this.running, - getEverosStatus: () => this.getEverosStatus(), + getEverosStatus: () => this.getEverosStatus(), + getCodeGraphStatus: () => this.getCodeGraphStatus(), watchProject: (project) => this.watchProject(project), unwatchProject: (projectId) => this.unwatchProject(projectId), }; @@ -178,9 +192,16 @@ export class Daemon { socketPath: getIpcSocketPath(), handler: (method, params) => this.handleIpc(method, params), }); - await this.ipcServer.start(); - - // 启动本地 Embedding/Rerank 模型服务(后台进行,不阻塞 IPC),就绪后自动启动 EverOS + await this.ipcServer.start(); + + try { + const codeGraphUrl = await this.codeGraphService.start(); + this.serviceRegistry.setCodeGraphUrl(codeGraphUrl); + } catch (err) { + logger.warn({ err }, 'CodeGraph Server 未启动,项目知识 Provider 暂不可用'); + } + + // 启动本地 Embedding/Rerank 模型服务(后台进行,不阻塞 IPC),就绪后自动启动 EverOS this.localModelManager .start() .then(async () => { @@ -246,10 +267,11 @@ export class Daemon { this.watcherTimeout = null; } // 停止所有角色服务 - for (const role of ROLES) { - await this.serviceRegistry.stop(role); - } - this.scanService.stop(); + for (const role of ROLES) { + await this.serviceRegistry.stop(role); + } + await this.codeGraphService.stop(); + this.scanService.stop(); this.scanJob?.stop(); this.scanJob = null; this.localModelManager.stop(); @@ -280,13 +302,17 @@ export class Daemon { return this.running; } - getEverosStatus(): EverosStatus { + getEverosStatus(): EverosStatus { return { state: this.everosState, url: this.everosHttpUrl, error: this.everosError, }; - } + } + + getCodeGraphStatus(): CodeGraphServiceStatus { + return this.codeGraphService.getStatus(); + } private async checkRemoteModels(): Promise { const [llm, multimodal] = await Promise.all([ diff --git a/src/advance/file-watcher.ts b/src/advance/file-watcher.ts index 148e9b2..5d05fb2 100755 --- a/src/advance/file-watcher.ts +++ b/src/advance/file-watcher.ts @@ -1,8 +1,7 @@ import { watch, type FSWatcher } from 'chokidar'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { minimatch } from 'minimatch'; -import type { ProjectConfig } from './config/project-config'; +import { matchesProjectPathPatterns, type ProjectConfig } from './config/project-config'; import type { WatchedEvent, WatchEventType } from './types'; import { logger } from '../core/logger'; import { logMemorySnapshot } from './classic/utils/memory-snapshot.js'; @@ -43,7 +42,7 @@ export class FileWatcher { const rel = normalized.startsWith(normalizedProjectRoot) ? normalized.slice(normalizedProjectRoot.length).replace(/^\//, '') : normalized; - return config.exclude.some((pattern) => minimatch(rel, pattern, { dot: true })); + return matchesProjectPathPatterns(rel, config.exclude); }; const usePolling = isWsl(); diff --git a/src/advance/ipc/handlers.ts b/src/advance/ipc/handlers.ts index 1ec3191..e65d36b 100755 --- a/src/advance/ipc/handlers.ts +++ b/src/advance/ipc/handlers.ts @@ -16,7 +16,11 @@ import { import { UndoExecutor } from '../archive/undo-executor'; import { detectGitInfo } from '../utils/git-info'; import { loadSoulContent, saveSoulContent } from '../classic/soul/soul-loader.js'; -import { loadProjectStatus, clearProjectError, recordProjectError } from '../classic/status/project-status-store.js'; +import { + loadProjectStatus, + clearProjectError, + recordProjectError, +} from '../classic/status/project-status-store.js'; import type { ScanService } from '../scan/scan-service.js'; import type { IGitProvider } from '../classic/provider/types.js'; import type { RoleServiceRegistry } from '../classic/role-service-registry.js'; @@ -24,14 +28,42 @@ import type { LocalModelServiceManager } from '../classic/memory/local-model-ser import type { ModelCapability } from '../classic/memory/model-server.js'; import { ReviewerManager } from '../classic/roles/reviewer-manager.js'; import { MaintainerManager } from '../classic/roles/maintainer-manager.js'; +import { ArchiverManager } from '../classic/roles/archiver-manager.js'; import type { Role, RoleConfig, GitlabConfig } from '../types.js'; import type { IRoleManager } from '../classic/roles/role-manager.js'; +import { ArchiverProviderOrchestrator } from '../archiver/provider-orchestrator.js'; +import type { CodeGraphServiceController } from '../archiver/codegraph-service.js'; import { readDirectoryTree } from '../utils/file-tree'; -import { everosMemorySearch, type EverOSSearchItem, everosMemoryGet, extractOwnersFromGetResult, type EverOSMemoryGetResult, type EverOSMemoryGetParams } from '../classic/memory/everos-api.js'; +import { + everosMemorySearch, + type EverOSSearchItem, + everosMemoryGet, + extractOwnersFromGetResult, + type EverOSMemoryGetResult, + type EverOSMemoryGetParams, +} from '../classic/memory/everos-api.js'; import { buildMemoryGraph } from '../classic/memory/graph-builder.js'; -import type { MemoryEntry, MemorySearchParams, MemoryDeleteParams } from '../../electron/shared/types.js'; -import type { DaemonStatus, EverosStatus, LocalModelStatus, RemoteModelStatus } from '../../electron/shared/service-status.js'; +import type { + ArchiverProviderDescriptor as PublicArchiverProviderDescriptor, + ArchiverProviderProbeResult as PublicArchiverProviderProbeResult, + ArchiverProviderRunReport as PublicArchiverProviderRunReport, + MemoryEntry, + MemorySearchParams, + MemoryDeleteParams, +} from '../../electron/shared/types.js'; +import type { + ArchiverProviderDescriptor as InternalArchiverProviderDescriptor, + ArchiverProviderProbeResult as InternalArchiverProviderProbeResult, + ArchiverProviderRunReport as InternalArchiverProviderRunReport, +} from '../archiver/provider-types.js'; +import type { + DaemonStatus, + CodeGraphServiceStatus, + EverosStatus, + LocalModelStatus, + RemoteModelStatus, +} from '../../electron/shared/service-status.js'; import type { RemoteModelChecker } from '../classic/memory/remote-model-checker.js'; export interface HandlerContext { @@ -77,6 +109,12 @@ export interface HandlerContext { isDaemonRunning?: () => boolean; /** 获取 EverOS 当前状态 */ getEverosStatus?: () => EverosStatus; + /** Daemon 托管的统一 CodeGraph 服务 */ + codeGraphService?: CodeGraphServiceController; + /** 获取 CodeGraph 服务当前状态 */ + getCodeGraphStatus?: () => CodeGraphServiceStatus; + /** Archiver Provider 编排器,测试或自定义运行时可替换 */ + archiverProviderOrchestrator?: ArchiverProviderOrchestrator; } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -124,9 +162,9 @@ export const handlers: Record Prom return { success: true }; }, - 'project.list': async (ctx) => { + 'project.list': async ctx => { const projects = ctx.registry.list(); - return projects.map((p) => { + return projects.map(p => { const dbCounts = ctx.store.getProjectCounts(p.id); const archiveRoot = getArchiveRoot(p); const statusPath = join(archiveRoot, 'status.json'); @@ -184,7 +222,10 @@ export const handlers: Record Prom const client = ctx.getClient(); if (!client) throw new Error('未配置 API Key'); if (!ctx.scanService) throw new Error('扫描服务未初始化'); - logger.info({ projectId: project.id, projectRoot: project.rootPath }, '收到手动扫描请求,已加入后台队列'); + logger.info( + { projectId: project.id, projectRoot: project.rootPath }, + '收到手动扫描请求,已加入后台队列' + ); ctx.scanService.scanProject(params.projectId); return { queued: true, scannedAt: Date.now() }; }, @@ -240,7 +281,7 @@ export const handlers: Record Prom 'action.history': async (ctx, params) => { if (params.projectId === 'all') { const projects = ctx.registry.list(); - return projects.flatMap((p) => ctx.store.listActionHistory(p.id)); + return projects.flatMap(p => ctx.store.listActionHistory(p.id)); } return ctx.store.listActionHistory(params.projectId); }, @@ -252,7 +293,7 @@ export const handlers: Record Prom return executor.undo(params.actionId); }, - 'daemon.config': async (ctx) => { + 'daemon.config': async ctx => { const fromDaemon = ctx.getDaemonConfig?.(); if (fromDaemon) { return fromDaemon; @@ -292,7 +333,7 @@ export const handlers: Record Prom const role = (params.role as string) ?? ''; const roleLower = role.toLowerCase(); const all = readFileSync(logPath, 'utf-8').split('\n'); - const filtered = all.filter((line) => line.toLowerCase().includes(roleLower)); + const filtered = all.filter(line => line.toLowerCase().includes(roleLower)); const count = params.lines ?? 100; return { lines: filtered.slice(-count) }; }, @@ -334,7 +375,9 @@ export const handlers: Record Prom if (params.everos !== undefined) { if (params.everos) { try { - config.everos = JSON.parse(params.everos) as import('../config/daemon-config.js').EverOSConfig; + config.everos = JSON.parse( + params.everos + ) as import('../config/daemon-config.js').EverOSConfig; } catch { throw new Error('EverOS 配置不是合法 JSON'); } @@ -351,27 +394,25 @@ export const handlers: Record Prom return { success: true }; }, - 'classic.start': async (ctx) => { + 'classic.start': async ctx => { await ctx.serviceRegistry.start('reviewer'); return { running: true }; }, - 'classic.stop': async (ctx) => { + 'classic.stop': async ctx => { await ctx.serviceRegistry.stop('reviewer'); return { running: false }; }, - 'classic.restart': async (ctx) => { + 'classic.restart': async ctx => { await ctx.serviceRegistry.stop('reviewer'); await ctx.serviceRegistry.start('reviewer'); return { running: true }; }, - 'classic.status': async (ctx) => { + 'classic.status': async ctx => { const projects = ctx.registry.list(); - const enabledProjects = projects.filter( - (p) => p.mrReview?.enabled && p.gitlab - ).length; + const enabledProjects = projects.filter(p => p.mrReview?.enabled && p.gitlab).length; const status = ctx.serviceRegistry.getStatus('reviewer'); return { running: status.running, @@ -441,7 +482,10 @@ export const handlers: Record Prom recordProjectError(project, err, 'gitlab-api'); } } - logger.warn({ err: message, gitlab: { baseUrl: gitlab.baseUrl, projectPath: gitlab.projectPath } }, 'GitLab 配置验证失败'); + logger.warn( + { err: message, gitlab: { baseUrl: gitlab.baseUrl, projectPath: gitlab.projectPath } }, + 'GitLab 配置验证失败' + ); throw new Error(`GitLab 配置验证失败:${message}`); } }, @@ -599,8 +643,8 @@ export const handlers: Record Prom const deletedSessions = new Set(ctx.store.listDeletedMemorySessions(projectId)); const entries: MemoryEntry[] = result.items - .filter((item) => !deletedSessions.has(item.sessionId ?? '')) - .map((item) => mapEverOSSearchItemToMemoryEntry(item)); + .filter(item => !deletedSessions.has(item.sessionId ?? '')) + .map(item => mapEverOSSearchItemToMemoryEntry(item)); return { entries }; }, @@ -612,13 +656,13 @@ export const handlers: Record Prom return { success: true }; }, - 'memory.graph': async (ctx) => { + 'memory.graph': async ctx => { const projects = ctx.registry.list(); // EverOS 尚未就绪时返回空记忆数据(保留项目节点),避免 UI 轮询抛错 if (!ctx.everosUrl) { return buildMemoryGraph({ - projects: projects.map((p) => ({ id: p.id, name: p.name, rootPath: p.rootPath })), + projects: projects.map(p => ({ id: p.id, name: p.name, rootPath: p.rootPath })), getResults: new Map(), }); } @@ -642,7 +686,13 @@ export const handlers: Record Prom } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.warn( - { err, projectId: params.projectId, ownerKind: params.ownerKind, ownerId: params.ownerId, memoryType: params.memoryType }, + { + err, + projectId: params.projectId, + ownerKind: params.ownerKind, + ownerId: params.ownerId, + memoryType: params.memoryType, + }, `EverOS memory/get 查询失败,跳过: ${message}` ); return { episodes: [], profiles: [], agent_cases: [], agent_skills: [], total_count: 0 }; @@ -666,7 +716,11 @@ export const handlers: Record Prom // 第一轮:用已知 agent 拉取 agent 侧数据,同时从 agent_case 中发现用户 for (const project of projects) { const projectResult: EverOSMemoryGetResult = { - episodes: [], profiles: [], agent_cases: [], agent_skills: [], total_count: 0, + episodes: [], + profiles: [], + agent_cases: [], + agent_skills: [], + total_count: 0, }; for (const agentId of knownAgents) { @@ -698,7 +752,6 @@ export const handlers: Record Prom getResults.set(project.id, projectResult); } - // 第二轮:用发现的 users 拉取 user 侧数据 for (const project of projects) { const projectResult = getResults.get(project.id); @@ -740,7 +793,7 @@ export const handlers: Record Prom } const graph = buildMemoryGraph({ - projects: projects.map((p) => ({ id: p.id, name: p.name, rootPath: p.rootPath })), + projects: projects.map(p => ({ id: p.id, name: p.name, rootPath: p.rootPath })), getResults, ownerDisplayNames, }); @@ -749,22 +802,28 @@ export const handlers: Record Prom // ---------- Daemon 状态 IPC Handler ---------- - 'daemon.status': async (ctx) => { + 'daemon.status': async ctx => { return { daemonRunning: ctx.isDaemonRunning?.() ?? false, everos: ctx.getEverosStatus?.() ?? { state: 'idle', url: null, error: null }, + codeGraph: ctx.getCodeGraphStatus?.() ?? { + state: 'idle', + url: null, + error: null, + activeJobs: 0, + queuedJobs: 0, + providers: [], + }, } satisfies DaemonStatus; }, // ---------- 本地模型服务 IPC Handler ---------- - 'localModel.status': async (ctx) => { - return ( - ctx.localModelManager?.getStatus() ?? { - embedding: { state: 'idle', url: null, error: null, progress: null }, - rerank: { state: 'idle', url: null, error: null, progress: null }, - } - ) satisfies LocalModelStatus; + 'localModel.status': async ctx => { + return (ctx.localModelManager?.getStatus() ?? { + embedding: { state: 'idle', url: null, error: null, progress: null }, + rerank: { state: 'idle', url: null, error: null, progress: null }, + }) satisfies LocalModelStatus; }, 'localModel.logs': async (ctx, params) => { @@ -772,19 +831,32 @@ export const handlers: Record Prom if (capability !== 'embedding' && capability !== 'rerank') { throw new Error(`无效的模型能力: ${capability}`); } - const lines = ctx.localModelManager?.getModelLogs(capability as ModelCapability, params.lines ?? 200) ?? []; + const lines = + ctx.localModelManager?.getModelLogs(capability as ModelCapability, params.lines ?? 200) ?? []; return { lines }; }, // ---------- 远端模型服务 IPC Handler ---------- - 'remoteModel.status': async (ctx) => { - return ( - ctx.getRemoteModelStatus?.() ?? { - llm: { state: 'unconfigured', modelLabel: '未配置', fullModel: '', baseUrl: null, error: null, lastCheckedAt: 0 }, - multimodal: { state: 'unconfigured', modelLabel: '未配置', fullModel: '', baseUrl: null, error: null, lastCheckedAt: 0 }, - } - ) satisfies RemoteModelStatus; + 'remoteModel.status': async ctx => { + return (ctx.getRemoteModelStatus?.() ?? { + llm: { + state: 'unconfigured', + modelLabel: '未配置', + fullModel: '', + baseUrl: null, + error: null, + lastCheckedAt: 0, + }, + multimodal: { + state: 'unconfigured', + modelLabel: '未配置', + fullModel: '', + baseUrl: null, + error: null, + lastCheckedAt: 0, + }, + }) satisfies RemoteModelStatus; }, // ---------- 角色 IPC Handler ---------- @@ -797,7 +869,14 @@ export const handlers: Record Prom }, 'project.role.config.update': async (ctx, params) => { - const { projectId, role, config } = params as { projectId: string; role: Role; config: RoleConfig }; + const { projectId, role, config } = params as { + projectId: string; + role: Role; + config: RoleConfig; + }; + if (!config || config.role !== role) { + throw new Error(`角色配置不匹配: 请求角色为 ${role},配置角色为 ${config?.role ?? '缺失'}`); + } const manager = createRoleManager(role, ctx.store); await manager.updateConfig(projectId, config); // 触发服务重启由后续 task 补齐 @@ -810,6 +889,32 @@ export const handlers: Record Prom return manager.getStatus(projectId); }, + 'archiver.provider.catalog': async ctx => { + const orchestrator = getArchiverProviderOrchestrator(ctx); + return { providers: orchestrator.listProviders().map(toPublicArchiverProviderDescriptor) }; + }, + + 'archiver.provider.probe': async (ctx, params) => { + const { projectId } = params as { projectId: string }; + const project = ctx.store.getProject(projectId); + if (!project) throw new Error(`项目不存在: ${projectId}`); + const orchestrator = getArchiverProviderOrchestrator(ctx); + const providers = await orchestrator.probeProject(project, getArchiveRoot(project)); + return { providers: providers.map(toPublicArchiverProviderProbeResult) }; + }, + + 'archiver.provider.status': async (ctx, params) => { + const { projectId } = params as { projectId: string }; + const project = ctx.store.getProject(projectId); + if (!project) throw new Error(`项目不存在: ${projectId}`); + const orchestrator = getArchiverProviderOrchestrator(ctx); + return { + status: toPublicArchiverProviderRunReport( + await orchestrator.readStatus(getArchiveRoot(project)) + ), + }; + }, + 'role.service.start': async (ctx, params) => { const { role } = params as { role: Role }; if (!ctx.serviceRegistry) throw new Error('角色服务注册表未初始化'); @@ -863,11 +968,101 @@ function createRoleManager(role: Role, store: MetadataStore): IRoleManager { return new ReviewerManager(store); case 'maintainer': return new MaintainerManager(store); + case 'archiver': + return new ArchiverManager(store); default: throw new Error(`未支持的角色: ${role}`); } } +function getArchiverProviderOrchestrator( + ctx: HandlerContext +): Pick { + return ( + ctx.codeGraphService ?? + ctx.archiverProviderOrchestrator ?? + new ArchiverProviderOrchestrator() + ); +} + +function toPublicArchiverProviderDescriptor( + descriptor: InternalArchiverProviderDescriptor +): PublicArchiverProviderDescriptor { + return { + id: descriptor.id, + displayName: descriptor.displayName, + description: descriptor.description, + homepage: descriptor.homepage, + license: descriptor.license, + kind: descriptor.kind, + automation: descriptor.automation, + placements: [...descriptor.placements], + capabilities: [...descriptor.capabilities], + preparation: + descriptor.kind === 'builtin' ? 'builtin' : descriptor.managedRuntime ? 'managed' : 'manual', + ...(descriptor.licenseNotice ? { licenseNotice: descriptor.licenseNotice } : {}), + }; +} + +function toPublicArchiverProviderProbeResult( + result: InternalArchiverProviderProbeResult +): PublicArchiverProviderProbeResult { + const message = toSafeProviderProbeMessage(result); + return { + providerId: result.providerId, + available: result.available, + readiness: result.readiness ?? (result.available ? 'ready' : 'unavailable'), + prepared: result.prepared ?? false, + ...(result.version ? { version: result.version } : {}), + ...(message ? { message } : {}), + }; +} + +function toSafeProviderProbeMessage( + result: InternalArchiverProviderProbeResult +): string | undefined { + if (result.readiness === 'ready' || result.available) { + return result.prepared ? '已通过 Provider 就绪检查。' : '已检测到可用的本机 Provider。'; + } + if (result.readiness === 'manual') { + const message = result.message ?? ''; + if (!result.prepared && /Git/i.test(message)) { + return 'Skill 自动准备失败:未检测到可用的 Git。'; + } + return result.prepared + ? 'Skill 已由系统自动准备;运行时需要 Agent 工作流调度。' + : 'Skill 自动准备未完成;运行时需要 Agent 工作流调度。'; + } + if (result.readiness === 'preparing') return '系统正在自动准备 Provider。'; + if (result.readiness === 'preparable') return '系统可自动准备该 Provider。'; + + const message = result.message ?? ''; + if (/Python 3|python/i.test(message)) return '自动准备失败:未检测到可用的 Python 3。'; + if (/npm/i.test(message)) return '自动准备失败:未检测到可用的 npm 运行时。'; + if (/Git/i.test(message)) return '自动准备失败:未检测到可用的 Git。'; + return 'Provider 自动准备或启动检查失败,系统会继续尝试其它知识源。'; +} + +function toPublicArchiverProviderRunReport( + report: InternalArchiverProviderRunReport | null +): PublicArchiverProviderRunReport | null { + if (!report) return null; + return { + schemaVersion: report.schemaVersion, + projectId: report.projectId, + generatedAt: report.generatedAt, + selectedPrimary: report.selectedPrimary, + statuses: report.statuses.map(status => ({ + providerId: status.providerId, + placement: status.placement, + state: status.state, + startedAt: status.startedAt, + finishedAt: status.finishedAt, + ...(status.version ? { version: status.version } : {}), + })), + }; +} + function mergeResult(target: EverOSMemoryGetResult, source: EverOSMemoryGetResult): void { target.episodes.push(...source.episodes); target.profiles.push(...source.profiles); diff --git a/src/advance/ipc/role-ipc-registry.ts b/src/advance/ipc/role-ipc-registry.ts index 24abd13..e0e3ead 100755 --- a/src/advance/ipc/role-ipc-registry.ts +++ b/src/advance/ipc/role-ipc-registry.ts @@ -35,26 +35,41 @@ export function registerRoleIPCHandlers(context: RoleIPCContext): void { managers.set(role, createRoleManager(role, store)); } - ipc.handle('project.role.config.get', async (_event, { projectId, role }: { projectId: string; role: Role }) => { - const manager = managers.get(role); - if (!manager) throw new Error(`未知角色: ${role}`); - const config = await manager.getConfig(projectId); - return { config }; - }); + ipc.handle( + 'project.role.config.get', + async (_event, { projectId, role }: { projectId: string; role: Role }) => { + const manager = managers.get(role); + if (!manager) throw new Error(`未知角色: ${role}`); + const config = await manager.getConfig(projectId); + return { config }; + } + ); - ipc.handle('project.role.config.update', async (_event, { projectId, role, config }: { projectId: string; role: Role; config: RoleConfig }) => { - const manager = managers.get(role); - if (!manager) throw new Error(`未知角色: ${role}`); - await manager.updateConfig(projectId, config); - // 触发服务重启由后续 task 补齐 - return { success: true }; - }); + ipc.handle( + 'project.role.config.update', + async ( + _event, + { projectId, role, config }: { projectId: string; role: Role; config: RoleConfig } + ) => { + const manager = managers.get(role); + if (!manager) throw new Error(`未知角色: ${role}`); + if (!config || config.role !== role) { + throw new Error(`角色配置不匹配: 请求角色为 ${role},配置角色为 ${config?.role ?? '缺失'}`); + } + await manager.updateConfig(projectId, config); + // 触发服务重启由后续 task 补齐 + return { success: true }; + } + ); - ipc.handle('project.role.status.get', async (_event, { projectId, role }: { projectId: string; role: Role }) => { - const manager = managers.get(role); - if (!manager) throw new Error(`未知角色: ${role}`); - return manager.getStatus(projectId); - }); + ipc.handle( + 'project.role.status.get', + async (_event, { projectId, role }: { projectId: string; role: Role }) => { + const manager = managers.get(role); + if (!manager) throw new Error(`未知角色: ${role}`); + return manager.getStatus(projectId); + } + ); ipc.handle('role.service.start', async (_event, { role }: { role: Role }) => { await serviceRegistry.start(role); @@ -66,14 +81,17 @@ export function registerRoleIPCHandlers(context: RoleIPCContext): void { return { success: true }; }); - ipc.handle('role.service.restart', async (_event, { role, projectId }: { role: Role; projectId?: string }) => { - if (projectId) { - await serviceRegistry.restartProject(role, projectId); - } else { - await serviceRegistry.restartProject(role, ''); + ipc.handle( + 'role.service.restart', + async (_event, { role, projectId }: { role: Role; projectId?: string }) => { + if (projectId) { + await serviceRegistry.restartProject(role, projectId); + } else { + await serviceRegistry.restartProject(role, ''); + } + return { success: true }; } - return { success: true }; - }); + ); ipc.handle('role.service.status', async (_event, { role }: { role: Role }) => { return serviceRegistry.getStatus(role); diff --git a/src/advance/llm/client.ts b/src/advance/llm/client.ts index 355efaa..53d7f8c 100755 --- a/src/advance/llm/client.ts +++ b/src/advance/llm/client.ts @@ -22,6 +22,14 @@ export class LlmDecisionError extends Error { } } +/** 模型完成多轮结构化输出兜底后仍未返回 JSON。 */ +export class LlmStructuredOutputError extends Error { + constructor(message: string) { + super(message); + this.name = 'LlmStructuredOutputError'; + } +} + /** 单轮 complete 响应允许的最大字符数,防止异常大响应撑爆堆内存 */ const MAX_COMPLETE_RESPONSE_CHARS = 200_000; @@ -567,7 +575,7 @@ export class LlmClient { if (converted) return converted; } - throw new Error('LLM 未返回 JSON 工具调用'); + throw new LlmStructuredOutputError('LLM 未返回 JSON 工具调用'); } private async completeOpenAIJson(prompt: string, system?: string): Promise { diff --git a/src/advance/llm/prompts/suggest-prompt.ts b/src/advance/llm/prompts/suggest-prompt.ts index a7f4998..73143a7 100755 --- a/src/advance/llm/prompts/suggest-prompt.ts +++ b/src/advance/llm/prompts/suggest-prompt.ts @@ -6,7 +6,6 @@ export interface SuggestInput { classification: ClassificationResult; dedupRelation?: 'duplicate' | 'conflict' | 'related' | 'unrelated'; relatedPath?: string; - proposedArchivePath: string; } /** @@ -24,38 +23,38 @@ export function buildSuggestPrompt(input: SuggestInput): string { 重要:你的回复正文必须且只能包含一个 JSON 对象,不要加 markdown 代码块、不要加解释、不要加思考过程。 可选动作: -- copy: 将源文件复制到归档目录(默认动作) -- organize: 将已归档文件在归档目录内重新组织到新的目标路径 +- copy: 将源文件复制到程序已计算的归档路径(默认动作) - ignore: 忽略此文件,不归档 -- flag: 标记此文件需要关注,但仍复制到归档目录的 flagged 子目录 +- flag: 标记此文件需要人工关注,程序会复制到 flagged 子目录 + +决策边界: +- 目标路径已由程序计算并校验,你不要生成、修改或讨论路径。 +- 当前输入是尚未归档的新文件,不要建议 organize。 JSON 字段要求: -- type: copy | organize | ignore | flag +- type: copy | ignore | flag - rationale: 决策说明 -- targetPath: 建议归档目标路径(copy/organize/flag 时必填) - risk: low | medium | high(仅用于日志可见性,不影响执行) - confidence: 0.0-1.0 - needsReview: boolean,是否需要人工复查 -归档路径规则: -- 格式://// -- 使用原始文件名(去除特殊字符) -- 如果文件已存在,追加 _1、_2 等序号 - 文件路径:${input.filePath} 分类:${input.classification.category}/${input.classification.docType} 摘要:${input.classification.summary} 去重关系:${input.dedupRelation ?? 'unrelated'}${input.relatedPath ? ` (${input.relatedPath})` : ''} -建议归档路径:${input.proposedArchivePath}${contentSnippet ? ` +${ + contentSnippet + ? ` 内容预览: -${contentSnippet}` : ''} +${contentSnippet}` + : '' +} 请直接输出如下格式的 JSON,不要任何其他内容: { "type": "copy", "rationale": "...", - "targetPath": "...", "risk": "low", "confidence": 0.9, "needsReview": false @@ -63,9 +62,8 @@ ${contentSnippet}` : ''} } export interface ParsedSuggestResponse { - type: 'copy' | 'organize' | 'ignore' | 'flag'; + type: 'copy' | 'ignore' | 'flag'; rationale: string; - targetPath?: string; risk: 'low' | 'medium' | 'high'; confidence: number; needsReview?: boolean; @@ -87,16 +85,15 @@ export function parseSuggestResponse(text: string): ParsedSuggestResponse | null try { const parsed = JSON.parse(cleaned.slice(i, j + 1)); if ( - ['copy', 'organize', 'ignore', 'flag'].includes(parsed.type) && + ['copy', 'ignore', 'flag'].includes(parsed.type) && typeof parsed.rationale === 'string' && ['low', 'medium', 'high'].includes(parsed.risk) && typeof parsed.confidence === 'number' && !Number.isNaN(parsed.confidence) ) { return { - type: parsed.type as 'copy' | 'organize' | 'ignore' | 'flag', + type: parsed.type as 'copy' | 'ignore' | 'flag', rationale: parsed.rationale, - targetPath: typeof parsed.targetPath === 'string' ? parsed.targetPath : undefined, risk: parsed.risk as 'low' | 'medium' | 'high', confidence: Math.min(1, Math.max(0, parsed.confidence)), needsReview: Boolean(parsed.needsReview), diff --git a/src/advance/pipeline/archive-pipeline.ts b/src/advance/pipeline/archive-pipeline.ts index b8a6ba7..3f67160 100755 --- a/src/advance/pipeline/archive-pipeline.ts +++ b/src/advance/pipeline/archive-pipeline.ts @@ -1,7 +1,6 @@ import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { relative } from 'node:path'; -import { minimatch } from 'minimatch'; import { ArchiveExecutor } from '../archive/archive-executor'; import { DocumentClassifier } from '../archive/classifier'; import { parseDocument } from '../archive/document-parser'; @@ -15,7 +14,7 @@ import type { LlmClient } from '../llm/client'; import type { MetadataStore } from '../store/metadata-store'; import type { Project, ArchiveAction, ClassificationResult } from '../types'; import { getArchiveRoot } from '../types'; -import { loadProjectConfig } from '../config/project-config'; +import { loadProjectConfig, matchesProjectPathPatterns } from '../config/project-config'; import { logger } from '../../core/logger'; export interface ArchivePipelineOptions { @@ -72,7 +71,7 @@ export class ArchivePipeline { for (const event of events) { try { const relPath = relative(project.rootPath, event.filePath).replace(/\\/g, '/'); - if (config.exclude.some((pattern) => minimatch(relPath, pattern, { dot: true }))) { + if (matchesProjectPathPatterns(relPath, config.exclude)) { processedEventIds.push(event.eventId); continue; } diff --git a/src/advance/project-scanner.ts b/src/advance/project-scanner.ts index 877295a..5d05045 100755 --- a/src/advance/project-scanner.ts +++ b/src/advance/project-scanner.ts @@ -1,15 +1,14 @@ import { readdir, stat } from 'node:fs/promises'; import { join, relative } from 'node:path'; -import { minimatch } from 'minimatch'; import type { MetadataStore } from './store/metadata-store'; import type { Project } from './types'; -import type { ProjectConfig } from './config/project-config'; +import { matchesProjectPathPatterns, type ProjectConfig } from './config/project-config'; /** * 让出事件循环,避免长时间同步 IO 阻塞 daemon 的 IPC/定时任务 */ async function yieldEventLoop(): Promise { - await new Promise((resolve) => setImmediate(resolve)); + await new Promise(resolve => setImmediate(resolve)); } /** @@ -47,7 +46,7 @@ export async function scanExistingFiles( const relPath = relative(project.rootPath, fullPath).replace(/\\/g, '/'); // 排除规则优先 - if (config.exclude.some((pattern) => minimatch(relPath, pattern, { dot: true }))) { + if (matchesProjectPathPatterns(relPath, config.exclude)) { continue; } @@ -60,7 +59,7 @@ export async function scanExistingFiles( if (isDirectory) { await walk(fullPath); - } else if (config.include.some((pattern) => minimatch(relPath, pattern, { dot: true }))) { + } else if (matchesProjectPathPatterns(relPath, config.include)) { if (!tracked.has(fullPath)) { matched.push(fullPath); } diff --git a/src/advance/store/metadata-store.ts b/src/advance/store/metadata-store.ts index 0192eb3..3e7e8c3 100755 --- a/src/advance/store/metadata-store.ts +++ b/src/advance/store/metadata-store.ts @@ -2,7 +2,23 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import Database from 'better-sqlite3'; import type { EverOSAddMessage } from '../classic/memory/everos-api.js'; -import type { Project, WatchedEvent, KnowledgeEntry, ArchiveAction, GitlabConfig, Role, RoleConfig, RoleFilter, ReviewerConfig } from '../types'; +import type { + Project, + WatchedEvent, + KnowledgeEntry, + ArchiveAction, + GitlabConfig, + Role, + RoleConfig, + RoleFilter, + ReviewerConfig, + MaintainerConfig, +} from '../types'; +import { isRoleConfigEnabled } from '../types.js'; +import { + createDefaultArchiverConfig, + normalizeArchiverConfig, +} from '../archiver/provider-config.js'; /** * SQLite 元数据存储封装 @@ -21,16 +37,18 @@ export class MetadataStore { private migrate(): void { // 旧版 projects 表字段迁移 - const projectColumns = this.db.prepare("PRAGMA table_info(projects)").all() as Array<{ name: string }>; - const hasArchiveRoot = projectColumns.some((c) => c.name === 'archive_root'); + const projectColumns = this.db.prepare('PRAGMA table_info(projects)').all() as Array<{ + name: string; + }>; + const hasArchiveRoot = projectColumns.some(c => c.name === 'archive_root'); if (!hasArchiveRoot) { this.db.exec('ALTER TABLE projects ADD COLUMN archive_root TEXT'); } - if (!projectColumns.some((c) => c.name === 'gitlab_config')) { + if (!projectColumns.some(c => c.name === 'gitlab_config')) { this.db.exec('ALTER TABLE projects ADD COLUMN gitlab_config TEXT'); } // mr_review_config 已废弃,迁移到 roles_config - if (!projectColumns.some((c) => c.name === 'roles_config')) { + if (!projectColumns.some(c => c.name === 'roles_config')) { this.db.exec("ALTER TABLE projects ADD COLUMN roles_config TEXT NOT NULL DEFAULT '{}'"); } @@ -61,34 +79,42 @@ export class MetadataStore { `); // mr_review_states 新增字段 - const mrStateColumns = this.db.prepare("PRAGMA table_info(mr_review_states)").all() as Array<{ name: string }>; - if (!mrStateColumns.some((c) => c.name === 'posted_discussions_json')) { + const mrStateColumns = this.db.prepare('PRAGMA table_info(mr_review_states)').all() as Array<{ + name: string; + }>; + if (!mrStateColumns.some(c => c.name === 'posted_discussions_json')) { this.db.exec('ALTER TABLE mr_review_states ADD COLUMN posted_discussions_json TEXT'); } - if (!mrStateColumns.some((c) => c.name === 'last_review_at')) { + if (!mrStateColumns.some(c => c.name === 'last_review_at')) { this.db.exec('ALTER TABLE mr_review_states ADD COLUMN last_review_at INTEGER'); } this.rebuildActionTablesIfNeeded(); // archive_actions 新增 source_path / archive_path - const actionColumns = this.db.prepare("PRAGMA table_info(archive_actions)").all() as Array<{ name: string }>; - if (!actionColumns.some((c) => c.name === 'source_path')) { + const actionColumns = this.db.prepare('PRAGMA table_info(archive_actions)').all() as Array<{ + name: string; + }>; + if (!actionColumns.some(c => c.name === 'source_path')) { this.db.exec('ALTER TABLE archive_actions ADD COLUMN source_path TEXT'); } - if (!actionColumns.some((c) => c.name === 'archive_path')) { + if (!actionColumns.some(c => c.name === 'archive_path')) { this.db.exec('ALTER TABLE archive_actions ADD COLUMN archive_path TEXT'); } // action_history 新增 archive_path - const historyColumns = this.db.prepare("PRAGMA table_info(action_history)").all() as Array<{ name: string }>; - if (!historyColumns.some((c) => c.name === 'archive_path')) { + const historyColumns = this.db.prepare('PRAGMA table_info(action_history)').all() as Array<{ + name: string; + }>; + if (!historyColumns.some(c => c.name === 'archive_path')) { this.db.exec('ALTER TABLE action_history ADD COLUMN archive_path TEXT'); } // archive_metadata 新增 type 列 - const metadataColumns = this.db.prepare("PRAGMA table_info(archive_metadata)").all() as Array<{ name: string }>; - if (!metadataColumns.some((c) => c.name === 'type')) { + const metadataColumns = this.db.prepare('PRAGMA table_info(archive_metadata)').all() as Array<{ + name: string; + }>; + if (!metadataColumns.some(c => c.name === 'type')) { this.db.exec("ALTER TABLE archive_metadata ADD COLUMN type TEXT NOT NULL DEFAULT 'copy'"); } @@ -155,7 +181,9 @@ export class MetadataStore { private rebuildActionTablesIfNeeded(): void { const tables = this.db - .prepare("SELECT name, sql FROM sqlite_master WHERE type='table' AND name IN ('archive_actions', 'action_history')") + .prepare( + "SELECT name, sql FROM sqlite_master WHERE type='table' AND name IN ('archive_actions', 'action_history')" + ) .all() as Array<{ name: string; sql: string }>; for (const { name, sql } of tables) { @@ -234,13 +262,37 @@ export class MetadataStore { } private normalizeRoles(raw: unknown): Record { + const defaultReviewer: ReviewerConfig = { + role: 'reviewer', + enabled: false, + reviewSchedule: '*/10 * * * *', + learningEnabled: true, + reviewerName: 'CodeKeeper Reviewer', + threadRiskLevels: ['CRITICAL', 'HIGH'], + }; + const defaultMaintainer: MaintainerConfig = { + role: 'maintainer', + enabled: false, + reviewSchedule: '*/10 * * * *', + learningEnabled: true, + maintainerName: 'CodeKeeper Maintainer', + autoFixEnabled: true, + resolveOthersDiscussions: true, + }; const defaults: Record = { - reviewer: { role: 'reviewer', enabled: false, reviewSchedule: '*/10 * * * *', learningEnabled: true, reviewerName: 'CodeKeeper Reviewer', threadRiskLevels: ['CRITICAL', 'HIGH'] }, - maintainer: { role: 'maintainer', enabled: false, reviewSchedule: '*/10 * * * *', learningEnabled: true, maintainerName: 'CodeKeeper Maintainer', autoFixEnabled: true, resolveOthersDiscussions: true }, - archiver: { role: 'archiver', enabled: false, reviewSchedule: '0 2 * * *', learningEnabled: true, archiverName: 'CodeKeeper Archiver' }, + reviewer: defaultReviewer, + maintainer: defaultMaintainer, + archiver: createDefaultArchiverConfig(), }; if (!raw || typeof raw !== 'object') return defaults; - return { ...defaults, ...(raw as Record) }; + const roles = raw as Partial>; + const reviewer = roles.reviewer?.role === 'reviewer' ? roles.reviewer : undefined; + const maintainer = roles.maintainer?.role === 'maintainer' ? roles.maintainer : undefined; + return { + reviewer: reviewer ?? defaultReviewer, + maintainer: maintainer ?? defaultMaintainer, + archiver: normalizeArchiverConfig(roles.archiver), + }; } close(): void { @@ -284,7 +336,9 @@ export class MetadataStore { } listProjects(): Project[] { - const rows = this.db.prepare('SELECT * FROM projects ORDER BY registered_at DESC').all() as Array<{ + const rows = this.db + .prepare('SELECT * FROM projects ORDER BY registered_at DESC') + .all() as Array<{ id: string; root_path: string; archive_root: string | null; @@ -294,7 +348,7 @@ export class MetadataStore { gitlab_config: string | null; roles_config: string; }>; - return rows.map((r) => ({ + return rows.map(r => ({ id: r.id, rootPath: r.root_path, archiveRoot: r.archive_root ?? undefined, @@ -334,7 +388,9 @@ export class MetadataStore { } updateLastScannedAt(projectId: string, timestamp: number): void { - this.db.prepare('UPDATE projects SET last_scanned_at = ? WHERE id = ?').run(timestamp, projectId); + this.db + .prepare('UPDATE projects SET last_scanned_at = ? WHERE id = ?') + .run(timestamp, projectId); } // ---------- 监听事件 ---------- @@ -357,7 +413,7 @@ export class MetadataStore { event_type: string; timestamp: number; }>; - return rows.map((r) => ({ + return rows.map(r => ({ eventId: r.id, projectId: r.project_id, filePath: r.file_path, @@ -373,13 +429,15 @@ export class MetadataStore { const rows = this.db .prepare('SELECT DISTINCT file_path FROM watch_events WHERE project_id = ? AND processed = 0') .all(projectId) as Array<{ file_path: string }>; - return rows.map((r) => r.file_path); + return rows.map(r => r.file_path); } markEventsProcessed(eventIds: number[]): void { if (eventIds.length === 0) return; const placeholders = eventIds.map(() => '?').join(','); - this.db.prepare(`UPDATE watch_events SET processed = 1 WHERE id IN (${placeholders})`).run(...eventIds); + this.db + .prepare(`UPDATE watch_events SET processed = 1 WHERE id IN (${placeholders})`) + .run(...eventIds); } // ---------- 知识条目 ---------- @@ -393,11 +451,21 @@ export class MetadataStore { status = excluded.status, updated_at = excluded.updated_at` ); - stmt.run(entry.id, entry.projectId, entry.filePath, entry.contentHash, entry.status, entry.createdAt, entry.updatedAt); + stmt.run( + entry.id, + entry.projectId, + entry.filePath, + entry.contentHash, + entry.status, + entry.createdAt, + entry.updatedAt + ); } listEntriesByProject(projectId: string): KnowledgeEntry[] { - const rows = this.db.prepare('SELECT * FROM knowledge_entries WHERE project_id = ?').all(projectId) as Array<{ + const rows = this.db + .prepare('SELECT * FROM knowledge_entries WHERE project_id = ?') + .all(projectId) as Array<{ id: string; project_id: string; file_path: string; @@ -406,7 +474,7 @@ export class MetadataStore { created_at: number; updated_at: number; }>; - return rows.map((r) => ({ + return rows.map(r => ({ id: r.id, projectId: r.project_id, filePath: r.file_path, @@ -424,7 +492,7 @@ export class MetadataStore { const rows = this.db .prepare('SELECT DISTINCT file_path FROM knowledge_entries WHERE project_id = ?') .all(projectId) as Array<{ file_path: string }>; - return rows.map((r) => r.file_path); + return rows.map(r => r.file_path); } // ---------- 分类 ---------- @@ -471,7 +539,9 @@ export class MetadataStore { listPendingActions(projectId: string): Array { const rows = this.db - .prepare('SELECT * FROM archive_actions WHERE project_id = ? AND executed = 0 ORDER BY created_at ASC') + .prepare( + 'SELECT * FROM archive_actions WHERE project_id = ? AND executed = 0 ORDER BY created_at ASC' + ) .all(projectId) as Array<{ id: string; entry_id: string; @@ -486,7 +556,7 @@ export class MetadataStore { confidence: number; created_at: number; }>; - return rows.map((r) => ({ + return rows.map(r => ({ id: r.id, sourcePath: r.entry_id, projectId: r.project_id, @@ -504,7 +574,11 @@ export class MetadataStore { if (actionIds.length === 0) return; const placeholders = actionIds.map(() => '?').join(','); const now = Date.now(); - this.db.prepare(`UPDATE archive_actions SET executed = 1, executed_at = ? WHERE id IN (${placeholders})`).run(now, ...actionIds); + this.db + .prepare( + `UPDATE archive_actions SET executed = 1, executed_at = ? WHERE id IN (${placeholders})` + ) + .run(now, ...actionIds); } // ---------- 动作历史(支持撤销) ---------- @@ -525,7 +599,11 @@ export class MetadataStore { ); } - getActionHistory(actionId: string): (ArchiveAction & { projectId: string; historyId: number; status: 'applied' | 'undone' }) | null { + getActionHistory( + actionId: string + ): + | (ArchiveAction & { projectId: string; historyId: number; status: 'applied' | 'undone' }) + | null { const r = this.db .prepare('SELECT * FROM action_history WHERE action_id = ? ORDER BY id DESC LIMIT 1') .get(actionId) as @@ -557,21 +635,23 @@ export class MetadataStore { }; } - listActionHistory(projectId: string): Array { + listActionHistory( + projectId: string + ): Array { const rows = this.db .prepare('SELECT * FROM action_history WHERE project_id = ? ORDER BY applied_at DESC') .all(projectId) as Array<{ - id: number; - action_id: string; - project_id: string; - type: string; - source_path: string; - archive_path: string | null; - target_path: string | null; - status: string; - applied_at: number; - }>; - return rows.map((r) => ({ + id: number; + action_id: string; + project_id: string; + type: string; + source_path: string; + archive_path: string | null; + target_path: string | null; + status: string; + applied_at: number; + }>; + return rows.map(r => ({ historyId: r.id, id: r.action_id, projectId: r.project_id, @@ -594,9 +674,15 @@ export class MetadataStore { // ---------- 项目统计 ---------- - getProjectCounts( - projectId: string - ): { pending: number; archived: number; ignored: number; orphaned: number; copied: number; organized: number; flagged: number } { + getProjectCounts(projectId: string): { + pending: number; + archived: number; + ignored: number; + orphaned: number; + copied: number; + organized: number; + flagged: number; + } { const entryCounts = this.db .prepare( `SELECT @@ -747,21 +833,21 @@ export class MetadataStore { const rows = this.db .prepare('SELECT * FROM archive_metadata WHERE project_id = ? ORDER BY archive_path') .all(projectId) as Array<{ - entry_id: string; - project_id: string; - source_path: string; - archive_path: string; - category: string; - doc_type: string; - tags: string; - summary: string; - content_hash: string; - type: string; - copied_at: number; - organized_at: number | null; - status: string; - }>; - return rows.map((r) => ({ + entry_id: string; + project_id: string; + source_path: string; + archive_path: string; + category: string; + doc_type: string; + tags: string; + summary: string; + content_hash: string; + type: string; + copied_at: number; + organized_at: number | null; + status: string; + }>; + return rows.map(r => ({ entryId: r.entry_id, projectId: r.project_id, sourcePath: r.source_path, @@ -778,10 +864,7 @@ export class MetadataStore { })); } - updateArchiveMetadataStatus( - entryId: string, - status: 'active' | 'orphaned' | 'superseded' - ): void { + updateArchiveMetadataStatus(entryId: string, status: 'active' | 'orphaned' | 'superseded'): void { this.db .prepare('UPDATE archive_metadata SET status = ? WHERE entry_id = ?') .run(status, entryId); @@ -805,7 +888,7 @@ export class MetadataStore { const project = this.getProject(projectId); if (!project) throw new Error(`项目不存在: ${projectId}`); const roles = project.roles ?? this.normalizeRoles({}); - roles[role] = config; + roles[role] = role === 'archiver' ? normalizeArchiverConfig(config) : config; this.db .prepare('UPDATE projects SET roles_config = ? WHERE id = ?') .run(JSON.stringify(roles), projectId); @@ -813,12 +896,24 @@ export class MetadataStore { getRoleEnabledProjects(role: Role): Project[] { return this.listProjects().filter( - (p) => p.gitlab !== null && p.roles?.[role]?.enabled === true + p => isRoleConfigEnabled(p.roles?.[role]) && (role === 'archiver' || p.gitlab != null) ); } /** @deprecated 请使用 updateProjectRoleConfig */ - updateMrReviewConfig(projectId: string, mrReview: { enabled: boolean; autoMergeMode: 'full' | 'audit'; reviewSchedule: string; learningEnabled: boolean; maxAutoMergeRisk: 'LOW' | 'MEDIUM' | 'HIGH'; autoFixEnabled?: boolean; resolveOthersDiscussions?: boolean; filter?: unknown }): void { + updateMrReviewConfig( + projectId: string, + mrReview: { + enabled: boolean; + autoMergeMode: 'full' | 'audit'; + reviewSchedule: string; + learningEnabled: boolean; + maxAutoMergeRisk: 'LOW' | 'MEDIUM' | 'HIGH'; + autoFixEnabled?: boolean; + resolveOthersDiscussions?: boolean; + filter?: unknown; + } + ): void { // 兼容旧调用方:将 mrReview 配置映射为 reviewer 角色配置 const reviewerConfig: ReviewerConfig = { role: 'reviewer', @@ -902,27 +997,32 @@ export class MetadataStore { ); } - getMrState(projectId: string, mrIid: number): { - id: string; - projectId: string; - mrIid: number; - sourceBranch: string; - targetBranch: string; - state: string; - title?: string; - webUrl?: string; - findingsJson?: string; - fixBranch?: string; - riskLevel?: string; - reviewerCommentsCount: number; - unresolvedCommentsCount: number; - ciStatus?: string; - lastReviewerCommentAt?: number; - postedDiscussionsJson?: string; - lastReviewAt?: number; - createdAt: number; - updatedAt: number; - } | undefined { + getMrState( + projectId: string, + mrIid: number + ): + | { + id: string; + projectId: string; + mrIid: number; + sourceBranch: string; + targetBranch: string; + state: string; + title?: string; + webUrl?: string; + findingsJson?: string; + fixBranch?: string; + riskLevel?: string; + reviewerCommentsCount: number; + unresolvedCommentsCount: number; + ciStatus?: string; + lastReviewerCommentAt?: number; + postedDiscussionsJson?: string; + lastReviewAt?: number; + createdAt: number; + updatedAt: number; + } + | undefined { const r = this.db .prepare('SELECT * FROM mr_review_states WHERE project_id = ? AND mr_iid = ?') .get(projectId, mrIid) as @@ -976,27 +1076,27 @@ export class MetadataStore { const rows = this.db .prepare('SELECT * FROM mr_review_states WHERE project_id = ? ORDER BY updated_at DESC') .all(projectId) as Array<{ - id: string; - project_id: string; - mr_iid: number; - source_branch: string; - target_branch: string; - state: string; - title: string | null; - web_url: string | null; - findings_json: string | null; - fix_branch: string | null; - risk_level: string | null; - reviewer_comments_count: number; - unresolved_comments_count: number; - ci_status: string | null; - last_reviewer_comment_at: number | null; - posted_discussions_json: string | null; - last_review_at: number | null; - created_at: number; - updated_at: number; - }>; - return rows.map((r) => ({ + id: string; + project_id: string; + mr_iid: number; + source_branch: string; + target_branch: string; + state: string; + title: string | null; + web_url: string | null; + findings_json: string | null; + fix_branch: string | null; + risk_level: string | null; + reviewer_comments_count: number; + unresolved_comments_count: number; + ci_status: string | null; + last_reviewer_comment_at: number | null; + posted_discussions_json: string | null; + last_review_at: number | null; + created_at: number; + updated_at: number; + }>; + return rows.map(r => ({ id: r.id, projectId: r.project_id, mrIid: r.mr_iid, @@ -1023,27 +1123,27 @@ export class MetadataStore { const rows = this.db .prepare('SELECT * FROM mr_review_states WHERE state = ? ORDER BY updated_at DESC') .all(state) as Array<{ - id: string; - project_id: string; - mr_iid: number; - source_branch: string; - target_branch: string; - state: string; - title: string | null; - web_url: string | null; - findings_json: string | null; - fix_branch: string | null; - risk_level: string | null; - reviewer_comments_count: number; - unresolved_comments_count: number; - ci_status: string | null; - last_reviewer_comment_at: number | null; - posted_discussions_json: string | null; - last_review_at: number | null; - created_at: number; - updated_at: number; - }>; - return rows.map((r) => ({ + id: string; + project_id: string; + mr_iid: number; + source_branch: string; + target_branch: string; + state: string; + title: string | null; + web_url: string | null; + findings_json: string | null; + fix_branch: string | null; + risk_level: string | null; + reviewer_comments_count: number; + unresolved_comments_count: number; + ci_status: string | null; + last_reviewer_comment_at: number | null; + posted_discussions_json: string | null; + last_review_at: number | null; + created_at: number; + updated_at: number; + }>; + return rows.map(r => ({ id: r.id, projectId: r.project_id, mrIid: r.mr_iid, @@ -1069,7 +1169,9 @@ export class MetadataStore { updateMrState( projectId: string, mrIid: number, - patch: Partial, 'id' | 'projectId' | 'mrIid' | 'createdAt'>> + patch: Partial< + Omit, 'id' | 'projectId' | 'mrIid' | 'createdAt'> + > ): void { const allowedFields = [ 'sourceBranch', @@ -1106,7 +1208,9 @@ export class MetadataStore { updatedAt: 'updated_at', }; - const entries = Object.entries(patch).filter(([key]) => allowedFields.includes(key as (typeof allowedFields)[number])); + const entries = Object.entries(patch).filter(([key]) => + allowedFields.includes(key as (typeof allowedFields)[number]) + ); if (entries.length === 0) return; const setClauses = entries.map(([key]) => { @@ -1117,7 +1221,9 @@ export class MetadataStore { const values = entries.map(([, value]) => value ?? null); this.db - .prepare(`UPDATE mr_review_states SET ${setClauses.join(', ')} WHERE project_id = ? AND mr_iid = ?`) + .prepare( + `UPDATE mr_review_states SET ${setClauses.join(', ')} WHERE project_id = ? AND mr_iid = ?` + ) .run(...values, projectId, mrIid); } @@ -1144,7 +1250,7 @@ export class MetadataStore { const rows = this.db .prepare('SELECT session_id FROM deleted_memory_sessions WHERE project_id = ?') .all(projectId) as Array<{ session_id: string }>; - return rows.map((r) => r.session_id); + return rows.map(r => r.session_id); } // ---------- 记忆 owner 注册表 ---------- @@ -1167,11 +1273,17 @@ export class MetadataStore { .run(projectId, ownerId, ownerType, displayName ?? null, now, now); } - listMemoryOwners(projectId: string): Array<{ ownerId: string; ownerType: 'user' | 'agent'; displayName?: string }> { + listMemoryOwners( + projectId: string + ): Array<{ ownerId: string; ownerType: 'user' | 'agent'; displayName?: string }> { const rows = this.db .prepare('SELECT owner_id, owner_type, display_name FROM memory_owners WHERE project_id = ?') - .all(projectId) as Array<{ owner_id: string; owner_type: 'user' | 'agent'; display_name: string | null }>; - return rows.map((r) => ({ + .all(projectId) as Array<{ + owner_id: string; + owner_type: 'user' | 'agent'; + display_name: string | null; + }>; + return rows.map(r => ({ ownerId: r.owner_id, ownerType: r.owner_type, displayName: r.display_name ?? undefined, @@ -1252,22 +1364,22 @@ export class MetadataStore { LIMIT ?` ) .all(now, limit) as Array<{ - id: string; - project_id: string; - app_id: string; - agent_id: string; - agent_display_name: string | null; - user_id: string; - session_id: string; - kind: string; - messages_json: string; - content_hash: string; - failure_count: number; - last_error: string | null; - next_retry_at: number; - created_at: number; - }>; - return rows.map((r) => ({ + id: string; + project_id: string; + app_id: string; + agent_id: string; + agent_display_name: string | null; + user_id: string; + session_id: string; + kind: string; + messages_json: string; + content_hash: string; + failure_count: number; + last_error: string | null; + next_retry_at: number; + created_at: number; + }>; + return rows.map(r => ({ id: r.id, projectId: r.project_id, appId: r.app_id, @@ -1289,11 +1401,7 @@ export class MetadataStore { this.db.prepare('DELETE FROM pending_memory_writes WHERE id = ?').run(id); } - incrementPendingMemoryFailure( - id: string, - nextRetryAt: number, - lastError: string - ): void { + incrementPendingMemoryFailure(id: string, nextRetryAt: number, lastError: string): void { this.db .prepare( `UPDATE pending_memory_writes diff --git a/src/advance/types.ts b/src/advance/types.ts index aa45333..22f133d 100755 --- a/src/advance/types.ts +++ b/src/advance/types.ts @@ -57,13 +57,39 @@ export interface MaintainerConfig extends BaseRoleConfig { ciFixEnabled?: boolean; } +/** Provider 内部知识提炼策略,不进入项目持久化配置。 */ +export type ArchiverSynthesisPolicy = 'off' | 'fallback' | 'always'; + +/** + * Provider 内部知识组合策略。 + * + * 该类型仅用于兼容旧的编排器调用,不属于 ArchiverConfig,用户不需要配置它。 + */ +export interface ArchiverProviderStrategy { + /** 按顺序尝试的项目知识源,第一个成功者作为主知识源 */ + sources: string[]; + /** 主知识源成功后追加执行的外部增强 Provider */ + augmenters: string[]; + /** 文档、约定、领域知识和维护风险的提炼策略 */ + synthesis: { + provider: string; + policy: ArchiverSynthesisPolicy; + }; +} + /** * Archiver 专属配置 */ -export interface ArchiverConfig extends BaseRoleConfig { +export interface ArchiverConfig { role: 'archiver'; - /** Archiver Agent 显示名称,用于日志/签名 */ - archiverName?: string; + schemaVersion: 3; + /** Archiver 在 EverOS 中的 Agent 显示名称 */ + archiverName: string; + /** 自动运行配置 */ + automation: { + enabled: boolean; + cron: string; + }; } /** @@ -71,6 +97,16 @@ export interface ArchiverConfig extends BaseRoleConfig { */ export type RoleConfig = ReviewerConfig | MaintainerConfig | ArchiverConfig; +/** 获取角色是否启用,兼容 Archiver 与其它角色配置 */ +export function isRoleConfigEnabled(config: RoleConfig | undefined): boolean { + return config?.role === 'archiver' ? config.automation.enabled : config?.enabled === true; +} + +/** 获取角色调度表达式,兼容 Archiver 与其它角色配置 */ +export function getRoleConfigSchedule(config: RoleConfig | undefined): string | undefined { + return config?.role === 'archiver' ? config.automation.cron : config?.reviewSchedule; +} + /** * 角色配置的类型映射,用于按角色窄化 */ diff --git a/src/assets/prompts/archiver-analyze.md b/src/assets/prompts/archiver-analyze.md index 82c1c79..1caeb1b 100755 --- a/src/assets/prompts/archiver-analyze.md +++ b/src/assets/prompts/archiver-analyze.md @@ -6,13 +6,16 @@ 关键文件路径: {{filePaths}} +关键文件内容: +{{fileContents}} + 请输出 JSON 数组,每个元素包含: { - "id": "唯一标识(建议用 category+简短英文)", - "category": "convention|architecture|domain|risk|stack", - "sourceFiles": ["相关文件路径"], - "content": "知识内容(中文)", - "confidence": "low|medium|high" +"id": "唯一标识(建议用 category+简短英文)", +"category": "convention|architecture|domain|risk|stack", +"sourceFiles": ["相关文件路径"], +"content": "知识内容(中文)", +"confidence": "low|medium|high" } 只输出 JSON,不要解释。 diff --git a/src/assets/prompts/recall-decision.md b/src/assets/prompts/recall-decision.md index 5f3955f..6ff552f 100755 --- a/src/assets/prompts/recall-decision.md +++ b/src/assets/prompts/recall-decision.md @@ -1,14 +1,19 @@ 你是一名记忆查询决策助手。请根据当前任务上下文,判断是否需要查询历史记忆。 可查询的记忆类型: + - review:与该 MR / 代码变更相关的历史评审经验。 - maintenance:与该问题相关的历史修复/维护经验。 -- project_knowledge:项目规范、架构约定、通用知识。 +- project_knowledge:Archiver Provider 维护的当前代码结构、架构、文档知识,以及共享记忆中的项目规范与通用知识。 - user_preferences:当前交互用户的历史偏好与习惯(必须提供 userId 时才使用)。 +本轮实际可用类型:{{availableRecallTypes}} + 决策原则: + - 只有当任务明显能从历史记忆中受益时才查询(例如需要上下文、用户偏好、往期类似问题)。 - 如果是简单问候、感谢、emoji、明显不需要记忆的判断,needsRecall 应为 false。 +- 涉及代码结构、调用关系、影响范围、架构边界或项目约定时,优先使用 project_knowledge。 - 不要为了查询而查询,避免浪费资源。 - 如果需要查询,给出 1~3 条具体 query,每条 query 应简洁且与任务相关。 @@ -19,9 +24,9 @@ 输出格式: { - "needsRecall": true|false, - "queries": [ - { "type": "review|maintenance|project_knowledge|user_preferences", "query": "...", "userId": "..." } - ], - "reason": "简短说明" +"needsRecall": true|false, +"queries": [ +{ "type": "review|maintenance|project_knowledge|user_preferences", "query": "...", "userId": "..." } +], +"reason": "简短说明" } diff --git a/src/electron/renderer/App.tsx b/src/electron/renderer/App.tsx index 7e34a20..9269ec7 100755 --- a/src/electron/renderer/App.tsx +++ b/src/electron/renderer/App.tsx @@ -21,12 +21,14 @@ import { Logs } from './pages/Logs.js'; import { Settings } from './pages/Settings.js'; import { MrReview } from './pages/MrReview.js'; import { Maintainer } from './pages/Maintainer.js'; +import { Archiver } from './pages/Archiver.js'; import { MemoryStatsPage } from './pages/MemoryStatsPage.js'; import { MemoryGraphPage } from './pages/MemoryGraphPage.js'; import { getAllRoleUIs } from './roles/role-registry.js'; // 触发角色 UI 注册(side-effect) import './roles/reviewer-role.js'; import './roles/maintainer-role.js'; +import './roles/archiver-role.js'; interface NavItem { to: string; @@ -83,12 +85,17 @@ function SidebarToggle() { aria-label={sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'} > - - {sidebarCollapsed ? ( - - ) : ( - - )} + + {sidebarCollapsed ? : } {sidebarCollapsed ? '展开' : '收起'} @@ -114,6 +121,7 @@ function AppContent() { !loading && daemon?.daemonRunning === true && daemon?.everos?.state === 'running' && + daemon?.codeGraph?.state === 'running' && localModel?.embedding?.state === 'running' && localModel?.rerank?.state === 'running'; @@ -138,11 +146,11 @@ function AppContent() {