diff --git a/.env.example b/.env.example deleted file mode 100755 index 1da70c8..0000000 --- a/.env.example +++ /dev/null @@ -1,43 +0,0 @@ -# ============================================================ -# CodeKeeper 环境变量配置 — 所有配置都在此文件完成 -# 复制为 .env 并填入实际值即可启动 -# ============================================================ - -# Anthropic API Key(用于 AI 代码审查,必填) -ANTHROPIC_API_KEY=sk-ant-xxxxx - -# ----------------------------------------------------------- -# 项目1: my-project(必填至少一个项目) -# ----------------------------------------------------------- -# 项目本地路径 -CODEKEEPER_PROJECT_MYPROJECT_PATH=/path/to/your/project -# GitLab 地址 -CODEKEEPER_PROJECT_MYPROJECT_GITLAB_URL=https://git.example.com -# GitLab 项目路径(group/project) -CODEKEEPER_PROJECT_MYPROJECT_GITLAB_PROJECT=group/your-project -# GitLab Token(或直接写 glpat-xxxxx) -CODEKEEPER_PROJECT_MYPROJECT_TOKEN=env:GITLAB_TOKEN -# 审查间隔(cron 表达式,默认每30分钟) -CODEKEEPER_PROJECT_MYPROJECT_SCHEDULE=*/30 * * * * -# 是否自动修复(true/false,默认 false) -CODEKEEPER_PROJECT_MYPROJECT_AUTO_FIX=false - -# ----------------------------------------------------------- -# 项目2: 其他项目(可选,复制上面的格式修改 ID 即可) -# ----------------------------------------------------------- -# CODEKEEPER_PROJECT_ANOTHER_PATH=/path/to/another -# CODEKEEPER_PROJECT_ANOTHER_GITLAB_URL=https://git.example.com -# CODEKEEPER_PROJECT_ANOTHER_GITLAB_PROJECT=group/another -# CODEKEEPER_PROJECT_ANOTHER_TOKEN=env:GITLAB_TOKEN_ANOTHER - -# ----------------------------------------------------------- -# 可选全局设置 -# ----------------------------------------------------------- -# 日志级别: debug | info | warn | error(默认 info) -# CODEKEEPER_LOG_LEVEL=info - -# 时区(默认 Asia/Shanghai) -# CODEKEEPER_TIMEZONE=Asia/Shanghai - -# AI 模型上下文限制(默认 200000) -# CODEKEEPER_TOKEN_BUDGET=200000 diff --git a/.eslintrc.cjs b/.eslintrc.cjs index dccddad..c85e24c 100755 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -7,10 +7,7 @@ module.exports = { project: './tsconfig.json', }, plugins: ['@typescript-eslint'], - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - ], + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], env: { node: true, es2022: true, diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f89e15f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +dist/ +vendor/ +node_modules/ +package-lock.json +tests/fixtures/ +src/assets/prompts/ +*.log diff --git a/DESIGN.md b/DESIGN.md index 3f793ba..9ede1d1 100755 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,460 +1,177 @@ -# CodeKeeper — 自动化 MR Reviewer & 学习体系实现计划 +# CodeKeeper — 架构设计文档 -> **C**ode **O**rchestrator & **R**eview **N**ode +> **定位**:本地优先的 **Agent 管线编排 + 项目智库**工作台。 +> ComfyUI 式可视化编排为皮,持久化执行为骨,双正本智库为底。 +> +> 本文档描述 repositioning 后的目标架构与当前实现状态。 +> 旧版 cron 审查器设计已随 M1 移除,不再保留于此。 -## Context +## 一、核心模型:分层管线图 -Boris Cherny 的核心理念是 "Automate yourself out of code review":将重复的审查意见记录并模式化,出现 3-4 次后写成 lint rule 自动化拦截。我们希望在独立 Mac 机器上部署一套长期运行的自动化服务,实现: +### 画布层(粗粒度) -1. **全量 MR AI 审查** — 自动发现 open MR,读取项目 CLAUDE.md 规则,进行深度代码审查 -2. **Reviewer 意见自动订正** — 根据 reviewer 的评论自动生成修复分支 -3. **学习循环** — 跟踪 reviewer 评论模式,自动更新 CLAUDE.md 和 ast-grep 规则 -4. **多项目支持** — 通过配置文件管理多个项目,每个项目独立调度 - -## Architecture - -### 部署模型 - -CodeKeeper 作为独立 TypeScript 项目,与目标项目分离部署: - -``` -/ai-framework/ -├── codekeeper/ # CodeKeeper 系统(本计划实现) -│ ├── src/ -│ ├── config/ -│ │ └── projects.yaml # 指定目标项目本地路径 -│ └── ... -│ -└── your-project/ # 目标项目(用户手动拷贝至此) - ├── .git/ - ├── CLAUDE.md # CodeKeeper 读取的规则源 - ├── .claude/rules/ # ast-grep 规则 - └── ... -``` - -运行方式: -```bash -cd /ai-framework/codekeeper -# 方式1:前台单次运行 -npm start -- --project /ai-framework/your-project - -# 方式2:后台 cron 模式 -npm run daemon -``` - -CodeKeeper 通过 `config/projects.yaml` 中的 `localPath` 定位项目,负责 `git fetch/pull` 同步,不管理项目 clone。 - -### 目录结构(独立仓库) - -``` -codekeeper/ -├── README.md -├── package.json # Node.js >=22, TypeScript -├── tsconfig.json -├── config/ -│ └── projects.example.yaml # 多项目配置模板 -├── src/ -│ ├── index.ts # 入口:解析 CLI args,启动 scheduler -│ ├── scheduler.ts # cron 调度 + worker 编排 -│ ├── cli.ts # CLI 接口(单次运行、项目注册等) -│ ├── config-loader.ts # projects.yaml 加载与验证(Zod schema) -│ ├── types.ts # 共享 TypeScript 类型 -│ ├── core/ -│ │ ├── project-sync.ts # git clone/pull/fetch(simple-git) -│ │ ├── token-budget.ts # 200k token 预算计算与分片策略 -│ │ ├── logger.ts # 结构化日志(pino) -│ │ └── rate-limiter.ts # API 调用速率限制 -│ ├── gitlab/ -│ │ ├── client.ts # GitLab API 封装(fetch-based) -│ │ ├── mr-service.ts # MR 查询、diff 获取、评论发布 -│ │ └── types.ts # GitLab API 类型定义 -│ ├── review/ -│ │ ├── diff-analyzer.ts # diff 解析、文件分组、风险评分 -│ │ ├── prompt-builder.ts # 构建 AI review prompt(CLAUDE.md + diff) -│ │ ├── reviewer.ts # Claude API 调用 + 结果解析 -│ │ ├── fixer.ts # 自动修复:创建分支 → 应用修改 → push -│ │ └── validators.ts # 修复后运行 lint/typecheck 验证 -│ ├── learn/ -│ │ ├── comment-fetcher.ts # 获取 MR reviewer 人工评论 -│ │ ├── pattern-tracker.ts # 评论分类 + 频次计数(Boris 3-4 次规则) -│ │ ├── claude-md-updater.ts # 自动更新项目 CLAUDE.md -│ │ └── ast-grep-generator.ts # 从模式生成 ast-grep 规则骨架 -│ └── ast-grep/ -│ ├── runner.ts # 调用本地 ast-grep CLI -│ └── types.ts # ast-grep 输出类型 -├── rules/ # 共享 ast-grep 规则模板 -│ └── common/ -│ ├── no-raw-env.yml -│ ├── require-feature-gate.yml -│ └── no-unprotected-override.yml -└── scripts/ - ├── install.sh # Mac 安装脚本 - ├── setup-launchd.sh # 配置 macOS launchd 服务 - └── com.codekeeper.review.plist # launchd plist 模板 -``` - -### 核心流程 - -#### 1. 单次 Review 工作流 - -``` -cron 触发 / CLI 调用 - │ - ▼ -┌─────────────────┐ -│ 1. 加载配置 │ 读取 projects.yaml,验证 API token -└────────┬────────┘ - │ - ▼ -┌─────────────────┐ -│ 2. 项目同步 │ git fetch origin,确保本地是最新代码 -└────────┬────────┘ - │ - ▼ -┌─────────────────┐ -│ 3. 发现 MR │ 调用 GitLab API 获取 open MR 列表 -│ (按过滤条件) │ 排除 draft、排除指定作者、按更新时间排序 -└────────┬────────┘ - │ - ▼ -┌─────────────────┐ -│ 4. 对每个 MR: │ -│ a. 获取 diff │ git diff target...source -│ b. 读取规则 │ 读取项目根目录 CLAUDE.md -│ c. 运行 ast- │ 执行项目 .claude/rules/ 规则 -│ grep 预检查 │ -│ d. 计算预算 │ 根据 diff 大小计算 token 需求 -│ e. 分片(如需) │ 超大 MR 按文件路径分组分批审查 -│ f. AI 审查 │ 调用 Claude API -│ g. 解析结果 │ JSON 格式 findings -│ h. 发布评论 │ 汇总为 GitLab MR comment -│ i. 自动修复 │ 如开启 autoFix,创建修复分支并 push -└─────────────────┘ - │ - ▼ -┌─────────────────┐ -│ 5. 记录日志 │ token 使用量、审查结果、错误 -└─────────────────┘ -``` - -#### 2. 学习循环工作流(独立 cron job,每日运行) - -``` -每日定时触发 - │ - ▼ -┌──────────────────────────┐ -│ 1. 扫描已合并 MR │ 获取最近 N 天合并的 MR -│ (最近 7 天) │ -└───────────┬──────────────┘ - │ - ▼ -┌──────────────────────────┐ -│ 2. 获取 reviewer 评论 │ 提取人工 reviewer 的评论文本 -└───────────┬──────────────┘ - │ - ▼ -┌──────────────────────────┐ -│ 3. 分类与计数 │ 按规则类别分类(命名/安全/类型/ │ -│ │ 重复/冗余/性能等),维护频次计数器 │ -└───────────┬──────────────┘ - │ - ▼ -┌──────────────────────────┐ -│ 4. 模式触发检查 │ 某类问题 count >= threshold(3) ? │ -└───────────┬──────────────┘ - │ - ┌──────┴──────┐ - │ 是 │ 否 - ▼ ▼ -┌──────────┐ ┌──────────┐ -│ 5a. 更新 │ │ 结束 │ -│ CLAUDE │ │ │ -│ .md │ │ │ -│ 追加规则│ │ │ -└─────┬─────┘ └──────────┘ - │ - ▼ -┌──────────┐ -│ 5b. 尝试 │ -│ 生成 │ -│ ast- │ -│ grep │ -│ 规则 │ -└─────┬────┘ - │ - ▼ -┌──────────┐ -│ 5c. 提交 │ -│ 规则 │ -│ 更新 │ -│ (MR) │ -└──────────┘ -``` - -### Token 预算管理(200k 限制) - -假设 200k 为**模型上下文窗口**(Claude 3.5 Sonnet/Opus): - -**预算分配:** -- 系统提示(角色定义 + 输出格式要求):~1k tokens -- CLAUDE.md 规则:~3k-8k tokens(取决于项目规则长度) -- diff 内容:变量,按实际变更计算 -- 输出预留:~20k tokens(给 AI 的审查结果) -- **可用审查预算**:200k - 1k - 8k - 20k = **~171k tokens** - -**diff token 估算:** -- 每行变更(+/-)≈ 5-20 tokens(取决于代码长度) -- 一个典型文件 diff(30 行变更)≈ 300-600 tokens -- 171k / 500 ≈ **每批可审查 340 个文件的 diff** - -**结论**:对于绝大多数 MR,200k 上下文足够单批完成审查。只有超大 MR(变更 500+ 文件)才需要分片。 - -**分片策略(超大 MR):** -1. 按文件路径前缀分组(同目录文件放一起,保持上下文) -2. 每批不超过预算的 80% -3. 标记分片关系("这是第 2/3 批审查") -4. 优先审查高风险文件(核心模块、安全相关、配置文件) - -### 多项目配置(projects.yaml) +管线由类型化节点与边组成,正本是项目内的 `.codekeeper/pipeline.yaml`(人类可读、可入库): ```yaml -# CodeKeeper 多项目配置 -projects: - - id: my-project - name: My Project - localPath: /Users/codekeeper/workspace/your-project - git: - remote: git@git.example.com:group/your-project.git - defaultBranch: main - gitlab: - baseUrl: https://git.example.com - projectPath: group/your-project - token: env:GITLAB_TOKEN - review: - enabled: true - schedule: "*/30 * * * *" # 每30分钟检查一次 - timezone: "Asia/Shanghai" - tokenBudget: 200000 - autoFix: true - autoFixBranchPrefix: "codekeeper/fix-" - rulesFile: CLAUDE.md # 相对于项目根目录 - astGrepConfig: .claude/rules/sgconfig.yml - filter: - excludeAuthors: [bot, ci] - excludeDrafts: true - minChanges: 1 - maxChanges: 300 # 超过则跳过,避免超大 MR 耗尽预算 - excludePaths: # 不审查的路径模式 - - "**/*.md" - - "docs/**" - learning: - enabled: true - schedule: "0 2 * * *" # 每天凌晨2点运行学习循环 - patternThreshold: 3 # Boris 的 3 次规则 - updateClaudeMd: true - createAstGrepRules: true - lookbackDays: 7 # 扫描最近7天的已合并 MR - - - id: another-project - name: Another Project - localPath: /Users/codekeeper/workspace/another - # ... 类似配置 -``` - -### 审查 Prompt 模板 - -``` -你是一个严格的代码审查员,请审查以下代码变更。 - -## 项目规则 -{project_rules} - -## ast-grep 预检结果 -{ast_grep_findings} - -## 变更内容(git diff) -```diff -{diff_content} -``` - -## 审查要求 -1. 检查是否违反上述项目规则 -2. 检查安全性问题(注入、泄露、越权等) -3. 检查代码质量问题(命名、类型、重复、冗余) -4. 检查可维护性问题(复杂度、测试、文档) - -对每个问题,按以下 JSON 格式输出: -{ - "findings": [ - { - "severity": "CRITICAL|HIGH|MEDIUM|LOW", - "file": "文件路径", - "line": 行号, - "ruleId": "规则ID(如存在)", - "message": "问题描述", - "suggestion": "具体的修复建议或代码片段" - } - ], - "summary": "审查总结", - "autoFixable": ["可自动修复的问题索引"] -} -``` - -### 自动修复流程 - -``` -1. reviewer.ts 返回 findings -2. fixer.ts 筛选 autoFixable 的问题 -3. 对每个可修复问题: - a. 切出修复分支:codekeeper/fix-{mr-iid}-{timestamp} - b. 读取原文件内容 - c. 构建修复 prompt(文件内容 + 修改要求) - d. 调用 Claude API 生成修改后的文件内容 - e. 写回文件 -4. 运行项目验证: - a. npm run lint(如果存在) - b. npm run typecheck(如果存在) - c. 失败则丢弃修复,记录日志 -5. git commit + push -6. 在 MR 评论中回复:"已创建修复分支 {branch},包含自动修复" -``` - -### 与现有基础设施的衔接 - -| 现有基础设施 | CodeKeeper 如何复用 | -|-------------|--------------| -| `.claude/rules/sgconfig.yml` + security rules | 直接读取并执行 ast-grep 预检 | -| `.claude/ci/mr-review.sh` | 提取 diff 获取、评论格式化逻辑 | -| `.claude/ci/lib/gitlab-api.sh` | 复用 API 调用模式和安全检查 | -| `CLAUDE.md` | 作为审查规则源,学习循环更新目标 | -| GitLab CI `mr-review` job | **互补**:CI 做快速反馈(秒级),CodeKeeper 做深度审查(分钟级)+ 自动修复 | -| ast-grep 规则格式 | 学习循环生成的新规则可直接被 CI 使用 | - -### Mac 部署方案 - -```bash -# 1. 安装 CodeKeeper -$ git clone ~/codekeeper -$ cd ~/codekeeper && npm install - -# 2. 配置项目 -$ cp config/projects.example.yaml config/projects.yaml -$ # 编辑 config/projects.yaml,填入项目信息 - -# 3. 配置环境变量 -$ cat ~/.codekeeper/env -ANTHROPIC_API_KEY=sk-ant-xxxxx -GITLAB_TOKEN=glpat-xxxxx - -# 4. 配置 launchd(长期运行) -$ npm run setup:mac -# 这会安装 ~/Library/LaunchAgents/com.codekeeper.review.plist - -# 5. 启动 -$ launchctl load ~/Library/LaunchAgents/com.codekeeper.review.plist -# 或前台运行:npm start - -# 6. 查看日志 -$ tail -f ~/Logs/codekeeper/codekeeper.log -``` - -## Implementation Tasks - -### Task 1: 项目脚手架 -- 初始化 npm 项目(TypeScript + Node.js >=22) -- 配置 tsconfig、eslint、prettier -- 安装依赖:simple-git, node-cron, pino, zod, dotenv -- 目录结构创建 - -### Task 2: 配置系统 -- Zod schema 定义 projects.yaml 结构 -- 配置加载器(支持环境变量插值如 `env:GITLAB_TOKEN`) -- 配置验证(本地路径存在性、token 有效性检查) -- CLI 命令:`codekeeper register ` 自动扫描项目生成配置 - -### Task 3: GitLab API 客户端 -- 封装 GitLab REST API(fetch-based) -- MR 查询(列表、详情、diff、changes) -- 评论发布(创建 MR note/discussion) -- 分支创建、push(通过本地 git) -- 错误处理和重试 - -### Task 4: 项目同步 -- simple-git 封装:clone、fetch、checkout -- SSH key 管理(支持 per-project SSH key) -- 工作目录隔离(每个项目在独立目录) -- 并发控制(避免同时操作同一仓库) - -### Task 5: Diff 分析器 + Token 预算 -- git diff 解析(提取变更文件、行号、内容) -- Token 估算(基于字符数 × 经验系数) -- 分片算法(按路径前缀分组,预算内最大化文件数) -- 风险评分(核心文件权重更高) - -### Task 6: Prompt 构建器 + AI 审查引擎 -- CLAUDE.md 读取与格式化 -- ast-grep 预检结果集成 -- Prompt 组装(规则 + diff + 格式要求) -- Claude API 调用(Anthropic SDK) -- 结果解析(JSON 提取与验证) -- 错误处理(API 失败、解析失败回退) - -### Task 7: 自动修复 -- 修复分支创建(基于 source branch) -- 文件修改 prompt(原文 + 修改要求 → 新内容) -- 本地验证(lint / typecheck 运行) -- git commit + push -- MR 评论回复 - -### Task 8: 学习循环 -- 已合并 MR 评论获取 -- 评论分类(关键词匹配 + 简单 NLP) -- 频次计数器(SQLite 或 JSON 文件持久化) -- CLAUDE.md 更新(追加规则段落) -- ast-grep 规则骨架生成(基于模式匹配) -- 规则更新 MR 创建 - -### Task 9: Scheduler + CLI -- node-cron 调度(per-project schedule) -- CLI 接口:`codekeeper run`(单次)、`codekeeper daemon`(后台)、`codekeeper status` -- 优雅退出(SIGTERM 处理) -- 运行状态报告 - -### Task 10: Mac 部署 -- install.sh 安装脚本 -- launchd plist 模板 -- 日志轮转配置 -- 健康检查端点(可选 HTTP) - -### Task 11: 与 your-project 集成 -- 复用现有 `.claude/rules/security/*.yml` -- 复用 `mr-review.sh` 的 diff 获取逻辑 -- 适配 your-project 的 CLAUDE.md 格式 -- 验证:对 fix/memory-sync 分支的 MR 进行审查测试 - -## Verification - -1. **单元测试**:每个模块独立测试(mock GitLab API、mock Claude API) -2. **集成测试**:对测试项目创建测试 MR,验证完整流程 -3. **Token 预算测试**:超大 diff 验证分片逻辑正确性 -4. **端到端测试**: - - 配置一个测试项目 - - 创建包含已知问题的测试 MR - - 运行 CodeKeeper,验证发现所有问题 - - 验证自动修复分支被正确创建 -5. **学习循环测试**: - - 模拟 3 个同类 reviewer 评论 - - 验证 CLAUDE.md 被正确更新 - - 验证 ast-grep 规则骨架生成 - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| Token 预算耗尽导致审查不完整 | 分片策略 + 预算预警日志 + maxChanges 过滤 | -| 自动修复引入新 bug | 修复后强制运行 lint/typecheck,失败则丢弃 | -| GitLab API 速率限制 | 请求队列 + 指数退避重试 + 速率限制跟踪 | -| CLAUDE.md 规则冲突 | 学习循环只追加不覆盖,人工审核规则更新 MR | -| 多项目并发导致资源争抢 | per-project 队列,全局并发限制 | -| 敏感信息泄露(API key) | 环境变量管理,不提交到 git,日志脱敏 | +version: 1 +id: pipeline-demo +nodes: + - id: trigger-reviewer + type: trigger.cron + params: { schedule: '*/10 * * * *' } + - id: role-reviewer + type: role.reviewer +edges: + - from: { node: trigger-reviewer, port: tick } + to: { node: role-reviewer, port: trigger } + channel: memory +``` + +**节点命名空间**:`trigger.*`(cron/文件/事件)、`role.*`(reviewer/maintainer/archiver)、 +`agent.*`(外部 Agent:a2a/subprocess/mcp,M5)、`knowledge.*`(召回/投影,M6)、`sink.*`(投递,预留,处理器后续里程碑实现)。 + +**边 = 类型化端口引用 + channel 绑定**: + +| channel | 语义 | 说明 | +| ------------------- | ------------------------- | -------------------------- | +| `memory` | 进程内直接传递(默认) | | +| `queue` | SQLite 队列异步传递 | 后续里程碑实体化 | +| `gitlab-discussion` | 经 GitLab discussion | **人类参与点**,显式在图上 | +| `everos` | 经 EverOS 记忆 | | +| `fs` | 经文件系统 | | +| `a2a-task` | 经 A2A 任务(外部 Agent) | M5 | + +### 钻取层(细粒度,M7) + +`NodeDef.subgraph` 由执行器解释:带子图且未注册处理器的节点递归执行子图, +stage 记录以 `parent/child` 命名(运行观测分层可见);子图入口节点继承父输入, +终态节点输出汇聚为父产物。画布支持钻取视图(面包屑返回)与"展开为子图" +(生成含 stage.role-run 复合 stage 的默认子图,reviewer 附带 advisory 的 +stage.ast-grep 预检)。stage.role-run 包装既有 Runner 黑箱,行为与现状一致。 + +## 二、运行时架构 + +``` +Electron 工作台 + │ IPC(unix socket / named pipe,换行分隔 JSON-RPC) + ▼ +daemon(Node.js) + ├── PipelineScheduler 管线生命周期中枢 + │ ├── 加载/迁移 pipeline.yaml(ensurePipelineDefinition) + │ ├── 按 trigger.cron 注册 node-cron 触发器 + │ └── start/stop/restartProject/getStatus(兼容旧 RoleServiceRegistry 表面) + ├── PipelineExecutor 顺序执行器(pipeline/core/executor.ts) + │ ├── 拓扑排序 + startFrom 下游子图裁剪 + │ ├── 节点边界落库(断点恢复) + │ └── resume:失败点续跑,成功节点产物回灌 + ├── RoleNodeRuntime role.* 节点执行载体 + │ └── 每 (项目, 角色) 一个长驻子进程(fork role-entry),IPC 派发 run 指令 + ├── PipelineRunStore pipeline_runs / stage_runs(SQLite,WAL) + ├── EverOS MCP bridge 项目级长期记忆 + └── CodeGraph Server 项目知识上下文 +``` + +### 与旧模型的对应关系 + +| 旧模型(M1 前) | 新模型 | +| ------------------------------- | ----------------------------------------- | +| 全局角色进程 × 项目轮询(10s) | (项目, 角色) 节点实例子进程,IPC 指令驱动 | +| Runner 内部 cron | 管线 trigger.cron 节点(daemon 调度) | +| startProjectLoop 立即执行 | start 角色后立即触发一轮 | +| restartProject 重启整个角色进程 | 停止单节点实例并重建,启用时立即对账一轮 | +| 角色配置散落在 roles_config | 自动迁移生成 pipeline.yaml 正本 | + +### 配置变更感知路径 + +- **角色配置更新**(`project.role.config.update`):落库后若管线正本仍带 + `# codekeeper:generated` 标记则按新配置重建该文件,随后 + `PipelineScheduler.reloadProject` 重排触发器并重启项目节点实例 + (子进程持有启动时项目快照,必须重建才能读到新配置)。 +- **pipeline.yaml 手工/画布编辑**:人类删除 generated 标记后文件即成为正本, + 角色配置更新不再回写;调度以人类正本为准。 +- **项目注册**:`project.register` 后 `reloadProject` 接入调度; + **注销**:`unloadProject` 摘除触发器并终止节点实例。 +- **角色服务启停**(`role.service.start/stop`):激活/摘除该角色全部触发器, + start 后立即对账执行一轮(对齐旧版 startProjectLoop 语义)。 + +### 角色行为不变量 + +Runner 内部逻辑(Reviewer/Maintainer/Archiver 的 Brain/Actor、记忆召回、 +GitLab 投递等)在 M3 保持黑箱不变;`runProjectOnce` 是唯一入口, +行为对拍由既有测试套件保证。 + +## 三、智库(当前 → 目标) + +**当前(M2 后)**:EverOS 承载经验记忆(episode/profile/agent_case/agent_skill), +Archiver 文件优先管线产出策展产物(context.md 等)。 + +**目标(M6 双正本)**: + +``` +策展知识层(唯一事实源,文件) + ├── 共享正本:/.codekeeper/knowledge/(入库,团队可见) + └── 私有正本:~/.codekeeper/memory/knowledge/(本地) + │ 投影管线 + ▼ +索引层(可重建):EverOS 向量索引 + CodeGraph + ▲ +经验记忆层(Agent 直写 EverOS):评审会话、修复尝试、交互 + │ 蒸馏管线(有人工检查点,承接旧 learn 循环) + └──→ 策展知识层 +``` + +## 四、里程碑路线 + +| 里程碑 | 内容 | 状态 | +| ------ | -------------------------------------------------------------------------- | ------ | +| M1 | 旧线清除;幸存者迁移入 advance;入口统一 | ✅ | +| M2 | 管线核心:图 schema / YAML 加载 / 顺序执行器 / 运行落库 | ✅ | +| M3 | Role 黑箱节点化;daemon 调度改造;配置自动迁移;本文档 | 进行中 | +| M4 | 可视化画布(节点编辑、检查器吸收 Role 配置、运行状态叠加) | ✅ | +| M5 | 外部 Agent 节点(A2A 词汇任务信封 + A2A/subprocess/MCP 适配器 + MCP 门面) | ✅ | +| M6 | 双正本智库 + 投影管线 + 蒸馏管线(人审闸门) | ✅ | +| M7 | 钻取层:Role 节点内 stage 子图拆解与编排 | ✅ | + +### 外部 Agent 接入(M5) + +- **任务信封契约**(`src/advance/agents/task-envelope.ts`):A2A 词汇的 + TaskEnvelope/TaskResult/AgentArtifact/AgentCapabilityCard;传输层可插拔。 +- **传输适配器**:`subprocess`(本地 CLI,stdin/stdout JSON)、`a2a` + (HTTP POST /tasks + AgentCard 探测;A2A 词汇的最小映射,**不与现网完整 + A2A 协议互通**)、`mcp`(MCP SSE 工具调用)。 +- **注册表**(daemon-config.json 的 `agents` 数组):`params.agentId` 引用注册项时 + **注册项优先**(pipeline.yaml 可入库,不允许项目内定义覆盖本机连接参数); + `agent.subprocess` 强制要求 agentId(命令只来自本机白名单);网络类传输允许内联参数。 +- **MCP 门面**(`src/advance/agents/mcp-facade.ts`):127.0.0.1 + 随机 token, + 工具:`pipeline_list_runs` / `pipeline_submit`(异步派发)/ `knowledge_recall`。 + 门面独立于 EverOS 启动,地址经 `daemon.status` 展示。 + +### 智库双正本(M6) + +- **正本**(唯一事实源,Markdown + frontmatter):共享 `/.codekeeper/knowledge/` + (随项目入库)+ 私有 `~/.codekeeper/memory/knowledge//`(本地)。 + 注意:人审草稿箱 `knowledge-inbox/` 也在项目内,未审的蒸馏草稿会随 git 可见—— + 团队不希望草稿入库时,请把 `.codekeeper/knowledge-inbox/` 加入项目 .gitignore。 +- **投影**:正本 → EverOS(内容哈希去重,状态表 knowledge_projection 含 root 维度; + flush 失败回滚状态下轮重投);EverOS/索引层随时可重建。 +- **蒸馏**:EverOS 经验 → LLM 候选 → 一律进 `knowledge-inbox/`(source: distilled), + 人类在 Knowledge tab 批准后才进入共享正本(承接旧 learn 循环的人审闸门)。 +- **召回口径**:`recall_project_knowledge` 覆盖 archiver 与 knowledge-projection 两个 owner。 + +## 五、工程约束(RULES) + +1. `build` + `test:vitest` + `lint` + `format` 四件套全绿才提交; +2. 测试零真实机器路径(临时目录 / `virtual-*` 相对路径),由脚本强制检查; +3. 凭据、用户目录、运行日志、EverOS 数据永不入库;管线 params 与产物明文落库, + 凭据只允许经 `RunContext.services` 注入; +4. 新依赖核验许可证并同步 `THIRD_PARTY_NOTICES.md`; +5. 跨平台路径处理(Windows 为主战场); +6. prompt 一律外置 `src/assets/prompts/`; +7. README 中英双语同步; +8. `vendor/everos` 为独立 submodule(Apache 2.0),不改动、不主张权利。 + +## 六、M8 UI 信息架构 + +UI 以项目为容器:全局侧边栏只保留仪表盘、智库、系统状态、设置;角色不再作为一级页面, +角色配置唯一入口是项目管线画布的节点检查器。项目内部固定为管线、运行、知识、归档、设置五个 tab; +日志由设置页打开独立窗口,归档动作历史归入项目运行中心,EverOS 记忆图谱与统计归入智库 tab。 diff --git a/README.md b/README.md index 4cdd22b..53f6802 100755 --- a/README.md +++ b/README.md @@ -42,15 +42,20 @@ The project is designed for teams that want an observable, recoverable automatio ## Core Capabilities -| Capability | Description | -| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | -| Multi-project management | Register multiple local projects with independent archive locations, GitLab settings, role switches, and runtime state. | -| Reviewer | Scan Merge Requests and produce structured review findings from project rules, changes, and historical memory. | -| Maintainer | Re-check findings, attempt fixes in an isolated Git worktree, validate changes, and deliver recoverable discussion replies. | -| Archiver | Organize project documentation through a file-first pipeline with archive, reorganize, ignore, mark, and undo operations. | -| Persistent memory | Use EverOS for project knowledge, role experience, cases, and skills, with graph and statistics views. | -| Desktop workbench | Manage projects, role settings, service health, logs, memory graphs, and archive history from Electron + React. | -| Daemon and CLI | Run scheduling, IPC, model access, and role lifecycles through the Node.js daemon; use the CLI for registration, scanning, history, and undo. | +| Capability | Description | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Pipeline orchestration | Project-level visual pipeline canvas: roles/triggers/knowledge/external agents as nodes; edits write back `.codekeeper/pipeline.yaml` with hot reload; role nodes drill into inner stage subgraphs. | +| External agents | A2A-vocabulary task envelopes with subprocess/A2A/MCP transport adapters; MCP facade (127.0.0.1 + token) exposes pipeline triggers and knowledge recall to external agents. | +| Project knowledge base | Curated knowledge dual canon (in-repo knowledge/ + local private) with EverOS projection for retrieval; experience distillation gated by human review. | +| Multi-project management | Register multiple local projects with independent archive locations, GitLab settings, role switches, and runtime state. | +| Reviewer | Scan Merge Requests and produce structured review findings from project rules, changes, and historical memory. | +| Maintainer | Re-check findings, attempt fixes in an isolated Git worktree, validate changes, and deliver recoverable discussion replies. | +| Archiver | Organize project documentation through a file-first pipeline with archive, reorganize, ignore, mark, and undo operations. | +| Persistent memory | Use EverOS for project knowledge, role experience, cases, and skills, with graph and statistics views. | +| Desktop workbench | Manage projects, role settings, service health, logs, memory graphs, and archive history from Electron + React. | +| Daemon and CLI | Run scheduling, IPC, model access, and role lifecycles through the Node.js daemon; use the CLI for registration, scanning, history, and undo. | + +The desktop workbench is project-centered: global navigation is consolidated into Dashboard, Knowledge, System Status, and Settings. Role configuration lives in the project pipeline canvas inspector. Project details provide Pipeline, Run, Knowledge, Archive, and Settings views; logs open in a separate window from Settings. The sections below describe the memory boundary, local setup, operational safeguards, and release rights. @@ -127,7 +132,7 @@ This builds the backend and starts the daemon, Vite, the Electron TypeScript wat 1. Configure the Agent LLM in **Settings**. 2. Select local embedding and reranking models; configure an EverOS multimodal model only when needed. 3. Register a local project from the **Dashboard** and choose an independent archive location when appropriate. -4. Enter the GitLab project URL, token, filters, and role settings on the role page. +4. Open the project, then configure the GitLab URL, token, filters, and role settings in the role node inspector on the Pipeline canvas. 5. Enable roles in a test project first, inspect the logs, and expand the scope only after the behavior is understood. ## Configuration @@ -147,7 +152,7 @@ The main settings include: - Local embedding and reranking models - EverOS multimodal model overrides -The Advance desktop flow uses the desktop settings and `daemon-config.json` as its source of truth. The root `.env.example` exists for compatibility entry points and should not be treated as the only desktop configuration source. +The Advance desktop flow uses the desktop settings and `daemon-config.json` as its source of truth. ### Project-level configuration diff --git a/README.zh-CN.md b/README.zh-CN.md index 0e2022d..697d814 100755 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -45,6 +45,12 @@ CodeKeeper Advance 是一个本地优先、面向多项目的 Agent 协作与知 | 能力 | 说明 | | -------------- | ------------------------------------------------------------------------------------------- | | 多项目管理 | 注册多个本地项目,分别维护归档位置、GitLab 配置、角色开关和运行状态。 | +| 管线编排 | 项目级可视化管线画布:Role/触发器/知识/外部 Agent 均为节点,编辑即写回 | +| | `.codekeeper/pipeline.yaml` 并热加载;角色节点可钻取为内部 stage 子图。 | +| 外部 Agent | A2A 词汇任务信封 + subprocess/A2A/MCP 传输适配器;MCP 门面(127.0.0.1+token) | +| | 向外部 Agent 暴露管线触发与知识召回。 | +| 项目智库 | 策展知识双正本(项目内 knowledge/ 入库 + 本地私有)+ EverOS 投影检索; | +| | 经验蒸馏经人审闸门入库。 | | Reviewer | 结合项目规则、变更内容和历史记忆扫描 Merge Request,生成结构化评审 finding。 | | Maintainer | 重新判断 finding,在隔离 Git worktree 中尝试修复、验证变更并可靠投递 discussion 回复。 | | Archiver | 通过文件优先管线整理项目文档,支持归档、重组、忽略、标记和撤销。 | @@ -52,6 +58,8 @@ CodeKeeper Advance 是一个本地优先、面向多项目的 Agent 协作与知 | 桌面工作台 | 使用 Electron + React 管理项目、角色设置、服务健康度、日志、记忆图谱和归档历史。 | | 守护进程与 CLI | Node.js 守护进程负责调度、IPC、模型访问和角色生命周期;CLI 提供注册、扫描、历史和撤销入口。 | +桌面工作台以项目为主要容器:全局导航集中到仪表盘、智库、系统状态和设置;角色配置在项目管线画布的节点检查器中完成。项目详情包含管线、运行、知识、归档和设置五个视图,日志从设置页打开独立窗口。 + 以下章节说明记忆边界、本地配置、运行安全措施和发布权利范围。 ## EverOS 记忆系统 @@ -127,7 +135,7 @@ npm run electron:dev:all 1. 在“设置”中配置 Agent 使用的大语言模型。 2. 选择本地 Embedding 与 Rerank 模型;仅在需要时配置 EverOS 多模态模型。 3. 在“仪表盘”注册本地项目,并按需选择独立归档位置。 -4. 在角色页面填写 GitLab 项目地址、Token、筛选条件和角色参数。 +4. 进入项目详情,在“管线”画布的角色节点检查器中填写 GitLab 项目地址、Token、筛选条件和角色参数。 5. 先在测试项目中启用角色并观察日志,确认行为后再扩大范围。 ## 配置 @@ -147,7 +155,7 @@ npm run electron:dev:all - 本地 Embedding / Rerank 模型 - EverOS 多模态模型覆盖配置 -Advance 桌面主流程以桌面设置和 `daemon-config.json` 为准。根目录的 `.env.example` 用于兼容入口,不应作为桌面主流程的唯一配置来源。 +Advance 桌面主流程以桌面设置和 `daemon-config.json` 为准。 ### 项目级配置 diff --git a/SECURITY.md b/SECURITY.md index e29f37b..6de736c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,7 +21,9 @@ This is an individually maintained project; responses are best-effort (initial r ## 本项目特有的安全注意点 / Project-specific Notes -- CodeKeeper 在**本地**处理 LLM API Key、GitLab Token 等敏感凭据(`.env`,永不入库)。提交 Issue、日志或崩溃报告时,请务必先脱敏。 - CodeKeeper handles sensitive credentials (LLM API keys, GitLab tokens) **locally** via `.env`, which is never committed. Please redact credentials before sharing logs or crash reports. +- CodeKeeper 在**本地**处理 LLM API Key、GitLab Token 等敏感凭据(`daemon-config.json`,永不入库)。提交 Issue、日志或崩溃报告时,请务必先脱敏。 + CodeKeeper handles sensitive credentials (LLM API keys, GitLab tokens) **locally** via `daemon-config.json`, which is never committed. Please redact credentials before sharing logs or crash reports. +- MCP 门面(外部 Agent 接入点)仅绑定 `127.0.0.1` 并携带启动期随机 token;其 `pipeline_submit` 具有真实世界写副作用(评审评论、代码修复、MR 合并)。**绑定本机即信任本机全部进程**——请勿在共享/多租户机器上运行,勿把门面 URL 转发到本机之外。 + The MCP facade binds to `127.0.0.1` with a per-boot random token; `pipeline_submit` has real-world write side effects. Running on this host implies trusting all local processes — do not run on shared machines or forward the facade URL off-host. - 若发现凭据意外进入 git 历史,请立即通过上述私密渠道告知,我们将重写历史并轮换凭据。 If you find credentials accidentally committed to git history, please report via the private channel above; we will rewrite history and rotate the credentials. diff --git a/config/projects.example.yaml b/config/projects.example.yaml deleted file mode 100755 index 7bda02e..0000000 --- a/config/projects.example.yaml +++ /dev/null @@ -1,38 +0,0 @@ -# CodeKeeper 多项目配置示例 -# 复制为 config/projects.yaml 并修改 - -projects: - - id: my-project - name: My Project - localPath: /path/to/project - git: - remote: git@git.example.com:group/project.git - defaultBranch: main - gitlab: - baseUrl: https://git.example.com - projectPath: group/project - token: env:GITLAB_TOKEN - review: - enabled: true - schedule: "*/30 * * * *" - timezone: "Asia/Shanghai" - tokenBudget: 200000 - autoFix: false - autoFixBranchPrefix: "codekeeper/fix-" - rulesFile: CLAUDE.md - astGrepConfig: .claude/rules/sgconfig.yml - filter: - excludeAuthors: [bot, ci] - excludeDrafts: true - minChanges: 1 - maxChanges: 300 - excludePaths: - - "**/*.md" - - "docs/**" - learning: - enabled: true - schedule: "0 2 * * *" - patternThreshold: 3 - updateClaudeMd: true - createAstGrepRules: true - lookbackDays: 7 diff --git a/package-lock.json b/package-lock.json index 0175ff2..5ce2775 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "@modelcontextprotocol/sdk": "^1.30.0", "better-sqlite3": "^12.0.0", "chokidar": "^4.0.0", - "dotenv": "^16.4.7", "node-cron": "^4.6.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", @@ -273,7 +272,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -322,7 +320,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1903,7 +1900,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2012,7 +2008,8 @@ "version": "2.0.46", "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/hast": { "version": "3.0.4", @@ -2080,7 +2077,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2091,7 +2087,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2138,7 +2133,6 @@ "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", @@ -2532,7 +2526,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3408,18 +3401,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3656,7 +3637,6 @@ "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", @@ -4560,7 +4540,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -4918,7 +4897,6 @@ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", @@ -6167,7 +6145,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6519,7 +6496,6 @@ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6530,7 +6506,6 @@ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7545,7 +7520,6 @@ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -7646,7 +7620,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7847,7 +7820,6 @@ "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" @@ -7899,7 +7871,6 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -8737,7 +8708,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -8794,7 +8764,6 @@ "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/package.json b/package.json index 6bb672c..17ed7a3 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "codekeeper-advance", "version": "0.1.0", "description": "通用、去品牌化、Agent 自驱动的知识智库系统", - "main": "dist/index.js", + "main": "dist/advance/cli-entry.js", "bin": { "codekeeper": "dist/advance/cli-entry.js" }, @@ -25,11 +25,11 @@ "electron:start": "npm run electron:build && node node_modules/electron/cli.js dist/electron/main/index.js" }, "keywords": [ - "code-review", - "gitlab", + "agent", + "pipeline", + "knowledge-base", "automation", - "ai", - "claude" + "ai" ], "license": "MIT", "engines": { @@ -40,7 +40,6 @@ "@modelcontextprotocol/sdk": "^1.30.0", "better-sqlite3": "^12.0.0", "chokidar": "^4.0.0", - "dotenv": "^16.4.7", "node-cron": "^4.6.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", diff --git a/scripts/setup-launchd.sh b/scripts/setup-launchd.sh index 9715965..7e6e167 100755 --- a/scripts/setup-launchd.sh +++ b/scripts/setup-launchd.sh @@ -26,8 +26,8 @@ cat > "$PLIST_PATH" <ProgramArguments /usr/local/bin/node - $CODEKEEPER_DIR/dist/index.js - --daemon + $CODEKEEPER_DIR/dist/advance/cli-entry.js + start WorkingDirectory $CODEKEEPER_DIR @@ -35,10 +35,6 @@ cat > "$PLIST_PATH" < PATH /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin - CODEKEEPER_CONFIG - $CODEKEEPER_DIR/config/projects.yaml - CODEKEEPER_LOG_DIR - $HOME/Logs/codekeeper RunAtLoad diff --git a/scripts/setup-systemd.sh b/scripts/setup-systemd.sh index 8638356..e2bb5f6 100755 --- a/scripts/setup-systemd.sh +++ b/scripts/setup-systemd.sh @@ -39,14 +39,10 @@ After=network.target Type=simple User=$RUN_USER WorkingDirectory=$CODEKEEPER_DIR -ExecStart=$NODE_PATH $CODEKEEPER_DIR/dist/index.js --daemon +ExecStart=$NODE_PATH $CODEKEEPER_DIR/dist/advance/cli-entry.js start Restart=always RestartSec=10 -Environment="CODEKEEPER_CONFIG=$CODEKEEPER_DIR/config/projects.yaml" -Environment="CODEKEEPER_LOG_DIR=$RUN_USER_HOME/Logs/codekeeper" Environment="PATH=/usr/local/bin:/usr/bin:/bin" -Environment="ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}" -Environment="GITLAB_TOKEN=${GITLAB_TOKEN:-}" [Install] WantedBy=multi-user.target @@ -69,7 +65,6 @@ if systemctl is-active --quiet "$SERVICE_NAME"; then echo "✅ CodeKeeper service installed and running!" echo " Service: $SERVICE_NAME" echo " Logs: journalctl -u $SERVICE_NAME -f" - echo " Config: $CODEKEEPER_DIR/config/projects.yaml" echo "" echo "Commands:" echo " Status: sudo systemctl status $SERVICE_NAME" diff --git a/scripts/setup-windows.ps1 b/scripts/setup-windows.ps1 index 1820e2c..c5efe51 100755 --- a/scripts/setup-windows.ps1 +++ b/scripts/setup-windows.ps1 @@ -4,7 +4,7 @@ $ErrorActionPreference = "Stop" $CornDir = Resolve-Path "$PSScriptRoot\.." -$TaskName = "CornReview" +$TaskName = "CodeKeeper" $LogDir = "$env:USERPROFILE\Logs\codekeeper" Write-Host "=== CodeKeeper Windows Service Setup ===" -ForegroundColor Cyan @@ -29,8 +29,15 @@ if ($existing) { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false } +# 清理旧版遗留任务名(CornReview 时代),避免其继续调用已下线的旧入口 +$legacy = Get-ScheduledTask -TaskName "CornReview" -ErrorAction SilentlyContinue +if ($legacy) { + Write-Host "Removing legacy task 'CornReview'..." + Unregister-ScheduledTask -TaskName "CornReview" -Confirm:$false +} + # Create scheduled task (runs every 30 minutes) -$Action = New-ScheduledTaskAction -Execute $NodePath -Argument "dist/index.js --daemon" -WorkingDirectory $CornDir +$Action = New-ScheduledTaskAction -Execute $NodePath -Argument "dist\advance\cli-entry.js start" -WorkingDirectory $CornDir $Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 30) -RepetitionDuration (New-TimeSpan -Days 3650) @@ -38,13 +45,6 @@ $Principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Service $Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -RunOnlyIfNetworkAvailable -# Environment variables -$EnvVars = @{ - CODEKEEPER_CONFIG = "$CornDir\config\projects.yaml" - CODEKEEPER_LOG_DIR = $LogDir - PATH = $env:PATH -} - Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Principal $Principal -Settings $Settings -Force | Out-Null Write-Host "`n✅ CodeKeeper scheduled task installed!" -ForegroundColor Green diff --git a/scripts/start.bat b/scripts/start.bat index 8a4d986..ea3e0d5 100755 --- a/scripts/start.bat +++ b/scripts/start.bat @@ -1,17 +1,17 @@ @echo off REM CodeKeeper startup script for Windows -REM Usage: start.bat [run|daemon|review|learn|status] +REM Usage: start.bat [start|register|unregister|list|status|process|history|undo] set CODEKEEPER_DIR=%~dp0.. cd /d "%CODEKEEPER_DIR%" -if "%1"=="" set CMD=run +if "%1"=="" set CMD=start if not "%1"=="" set CMD=%1 -if "%CMD%"=="daemon" ( - echo Starting CodeKeeper in daemon mode... - start /min cmd /c "node dist/index.js --daemon > %USERPROFILE%\Logs\codekeeper\codekeeper.log 2>&1" +if "%CMD%"=="start" ( + echo Starting CodeKeeper daemon... + start /min cmd /c "node dist/advance/cli-entry.js start > %USERPROFILE%\Logs\codekeeper\codekeeper.log 2>&1" echo Daemon started. Logs: %USERPROFILE%\Logs\codekeeper\ ) else ( - node dist/index.js %CMD% %2 %3 %4 %5 + node dist/advance/cli-entry.js %CMD% %2 %3 %4 %5 ) diff --git a/src/advance/agents/mcp-facade.ts b/src/advance/agents/mcp-facade.ts new file mode 100644 index 0000000..a2de817 --- /dev/null +++ b/src/advance/agents/mcp-facade.ts @@ -0,0 +1,217 @@ +/** + * CodeKeeper MCP 门面:把管线与智库能力暴露给外部 Agent(方向 B)。 + * + * 外部 Agent(OpenCode、Claude Code、自研工具等)作为 MCP client 连接后, + * 可以查询管线运行状态、手动触发角色执行、召回项目知识。 + * + * 安全模型: + * - 仅监听 127.0.0.1; + * - 启动时生成随机 token 编入门面 URL(http://127.0.0.1:port/sse?token=...), + * 未带正确 token 的连接一律 401;URL 经 daemon 状态展示给本机用户。 + * - pipeline_submit 具有真实世界写副作用(评审评论/修复/合并), + * 绑定本机 + token 即"信任本机持有者"模型,详见 SECURITY.md。 + */ + +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { logger } from '../core/logger.js'; +import { everosMemorySearchProject } from '../classic/memory/everos-api.js'; +import type { PipelineScheduler } from '../pipeline/pipeline-scheduler.js'; +import { ROLES, type Role } from '../types.js'; + +export interface McpFacadeOptions { + scheduler: PipelineScheduler; + /** EverOS HTTP 服务地址;未就绪时知识召回返回明确错误 */ + getEverosUrl: () => string | null; + port?: number; +} + +const APP_ID = 'codekeeper-advance'; + +export class McpFacadeServer { + private httpServer: http.Server | null = null; + /** 活动 SSE 会话:stop 时主动关闭,避免 daemon 无法退出 */ + private readonly sessions = new Map(); + private readonly token = randomUUID(); + + constructor(private readonly options: McpFacadeOptions) {} + + /** 启动并返回带 token 的门面地址 */ + async start(): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => void this.handleRequest(req, res)); + server.on('error', reject); + this.httpServer = server; + + server.listen(this.options.port ?? 0, '127.0.0.1', () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + // 直接返回可用的 SSE 端点(含 token),复制即可接入 + resolve(`http://127.0.0.1:${port}/sse?token=${this.token}`); + }); + }); + } + + async stop(): Promise { + for (const session of this.sessions.values()) { + try { + await session.server.close(); + await session.transport.close(); + } catch (error) { + logger.warn({ err: error }, '关闭 MCP 门面会话失败'); + } + } + this.sessions.clear(); + await new Promise(resolve => { + this.httpServer?.closeAllConnections(); + this.httpServer?.close(() => resolve()); + }); + this.httpServer = null; + } + + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const url = new URL(req.url ?? '/', `http://${req.headers.host}`); + + if (url.searchParams.get('token') !== this.token) { + res.writeHead(401); + res.end('unauthorized'); + return; + } + + if (url.pathname === '/sse') { + const transport = new SSEServerTransport(`/messages?token=${this.token}`, res); + const sessionId = transport.sessionId; + const server = new Server( + { name: 'codekeeper-facade-mcp', version: '0.1.0' }, + { capabilities: { tools: {} } } + ); + this.registerTools(server); + this.sessions.set(sessionId, { transport, server }); + + res.on('close', async () => { + this.sessions.delete(sessionId); + try { + await server.close(); + } catch (error) { + logger.warn({ err: error, sessionId }, '关闭 MCP 门面会话失败'); + } + }); + + await server.connect(transport); + return; + } + + if (url.pathname === '/messages') { + const sessionId = url.searchParams.get('sessionId') ?? ''; + const session = this.sessions.get(sessionId); + if (!session) { + res.writeHead(404); + res.end('session not found'); + return; + } + await session.transport.handlePostMessage(req, res); + return; + } + + res.writeHead(404); + res.end('not found'); + } + + private registerTools(server: Server): void { + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'pipeline_list_runs', + description: '查询项目管线的最近运行记录(含节点级状态)', + inputSchema: { + type: 'object', + properties: { + projectId: { type: 'string' }, + limit: { type: 'number' }, + }, + required: ['projectId'], + }, + }, + { + name: 'pipeline_submit', + description: '手动触发项目某个角色节点立即执行一轮(异步派发,不等待执行完成)', + inputSchema: { + type: 'object', + properties: { + projectId: { type: 'string' }, + role: { type: 'string', enum: [...ROLES] }, + }, + required: ['projectId', 'role'], + }, + }, + { + name: 'knowledge_recall', + description: '召回项目长期记忆中的相关知识(EverOS)', + inputSchema: { + type: 'object', + properties: { + projectId: { type: 'string' }, + query: { type: 'string' }, + topK: { type: 'number' }, + }, + required: ['projectId', 'query'], + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async request => { + const args = (request.params.arguments ?? {}) as Record; + try { + switch (request.params.name) { + case 'pipeline_list_runs': + return jsonResult( + this.options.scheduler.listPipelineRuns( + String(args.projectId), + typeof args.limit === 'number' ? args.limit : 10 + ) + ); + case 'pipeline_submit': { + const role = String(args.role); + if (!ROLES.includes(role as Role)) { + return jsonResult({ error: `未知角色: ${role}` }); + } + // 异步派发:角色执行可达分钟级,不阻塞 MCP client + void this.options.scheduler + .runProjectRoleNow(String(args.projectId), role as Role) + .catch(error => { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`[MCP门面] pipeline_submit 执行失败: ${message}`); + }); + return jsonResult({ submitted: true }); + } + case 'knowledge_recall': { + const everosUrl = this.options.getEverosUrl(); + if (!everosUrl) { + return jsonResult({ error: 'EverOS 未就绪,知识召回不可用' }); + } + const result = await everosMemorySearchProject(everosUrl, { + appId: APP_ID, + projectId: String(args.projectId), + query: String(args.query), + topK: typeof args.topK === 'number' ? args.topK : 5, + }); + return jsonResult(result); + } + default: + return jsonResult({ error: `未知工具: ${request.params.name}` }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return jsonResult({ error: message }); + } + }); + } +} + +function jsonResult(value: unknown) { + return { content: [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }] }; +} diff --git a/src/advance/agents/registry.ts b/src/advance/agents/registry.ts new file mode 100644 index 0000000..b8425ff --- /dev/null +++ b/src/advance/agents/registry.ts @@ -0,0 +1,149 @@ +/** + * 外部 Agent 注册表(推广自 Archiver Provider 注册表模式)。 + * + * Agent 规格来源:daemon-config.json 的 agents 数组(应用级配置)。 + * 管线 agent.* 节点经 params.agentId 引用注册表中的 Agent; + * 节点也可内联连接参数(不注册的一次性对接),注册表提供健康探测与解析。 + */ + +import { z } from 'zod'; +import { logger } from '../core/logger.js'; +import type { AgentCapabilityCard } from './task-envelope.js'; +import { + A2aTransport, + McpTransport, + SubprocessTransport, + type AgentTransport, +} from './transports.js'; + +/** Agent 注册规格(daemon-config.json 的 agents 数组元素) */ +export const agentSpecSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + capabilities: z.array(z.string()).default([]), + transport: z.enum(['a2a', 'subprocess', 'mcp']), + /** a2a:对端 base URL */ + endpoint: z.string().optional(), + /** subprocess:可执行命令与参数 */ + command: z.string().optional(), + args: z.array(z.string()).optional(), + /** mcp:MCP server URL 与工具名 */ + serverUrl: z.string().optional(), + tool: z.string().optional(), +}); +export type AgentSpec = z.infer; + +export class AgentRegistry { + private readonly specs = new Map(); + + constructor(specs: AgentSpec[] = []) { + for (const spec of specs) this.register(spec); + } + + register(spec: AgentSpec): void { + this.specs.set(spec.id, spec); + } + + get(agentId: string): AgentSpec | undefined { + return this.specs.get(agentId); + } + + list(): AgentSpec[] { + return [...this.specs.values()]; + } + + /** + * 解析传输实例。 + * - params.agentId 引用注册项时,**注册项优先**(pipeline.yaml 是可入库正本, + * 不允许项目内定义覆盖本机 daemon-config 中的连接参数,否则 clone 即投毒); + * - 内联 params 一次性对接仅允许网络类传输(a2a/mcp); + * agent.subprocess 强制要求 agentId(可执行命令只能来自本机白名单)。 + */ + resolveTransport(nodeType: string, params: Record): AgentTransport { + const agentId = typeof params.agentId === 'string' ? params.agentId : undefined; + const spec = agentId ? this.specs.get(agentId) : undefined; + if (agentId && !spec) { + throw new Error(`未注册的外部 Agent: ${agentId}`); + } + if (!spec && nodeType === 'agent.subprocess') { + throw new Error( + 'agent.subprocess 节点必须引用已注册 Agent(params.agentId);命令只允许来自本机 daemon-config 白名单' + ); + } + // 注册项优先于节点内联参数 + const merged = { ...params, ...spec } as AgentSpec & Record; + + switch (nodeType) { + case 'agent.subprocess': { + const command = merged.command; + if (typeof command !== 'string' || !command) { + throw new Error('agent.subprocess 节点缺少 params.command'); + } + return new SubprocessTransport({ command, args: asStringArray(merged.args) }); + } + case 'agent.a2a': { + const endpoint = merged.endpoint; + if (typeof endpoint !== 'string' || !endpoint) { + throw new Error('agent.a2a 节点缺少 params.endpoint'); + } + return new A2aTransport({ endpoint }); + } + case 'agent.mcp': { + const { serverUrl, tool } = merged; + if (typeof serverUrl !== 'string' || !serverUrl || typeof tool !== 'string' || !tool) { + throw new Error('agent.mcp 节点缺少 params.serverUrl / params.tool'); + } + return new McpTransport({ serverUrl, tool }); + } + default: + throw new Error(`未知 agent 节点类型: ${nodeType}`); + } + } + + /** 健康探测:返回每个注册 Agent 的可达性 */ + async probeAll(): Promise> { + const results: Array<{ id: string; ok: boolean }> = []; + for (const spec of this.specs.values()) { + let ok = false; + try { + const transport = this.resolveTransport(`agent.${spec.transport}`, { + agentId: spec.id, + }); + ok = await transport.probe(); + } catch (error) { + logger.warn({ err: error, agentId: spec.id }, '外部 Agent 探测失败'); + } + results.push({ id: spec.id, ok }); + } + return results; + } + + /** 能力卡片列表(A2A 对端尝试实时拉取,失败回退注册信息) */ + async listCards(): Promise { + const cards: AgentCapabilityCard[] = []; + for (const spec of this.specs.values()) { + if (spec.transport === 'a2a' && spec.endpoint) { + const card = await new A2aTransport({ endpoint: spec.endpoint }).fetchCard(); + cards.push(card ?? specToCard(spec)); + } else { + cards.push(specToCard(spec)); + } + } + return cards; + } +} + +function specToCard(spec: AgentSpec): AgentCapabilityCard { + return { + id: spec.id, + name: spec.name, + description: spec.description, + capabilities: spec.capabilities, + transport: spec.transport, + }; +} + +function asStringArray(value: unknown): string[] | undefined { + return Array.isArray(value) ? value.filter(item => typeof item === 'string') : undefined; +} diff --git a/src/advance/agents/task-envelope.ts b/src/advance/agents/task-envelope.ts new file mode 100644 index 0000000..669652d --- /dev/null +++ b/src/advance/agents/task-envelope.ts @@ -0,0 +1,57 @@ +/** + * 外部 Agent 任务信封契约(A2A 词汇)。 + * + * 管线 agent.* 节点与外部 Agent 的交互统一为: + * 能力描述(AgentCapabilityCard)+ 任务信封(TaskEnvelope)+ 结果(TaskResult)。 + * 传输层(A2A HTTP / 本地子进程 / MCP 工具)是可插拔适配器,契约保持一致, + * 未来接入完整 A2A 协议时只需替换 transport 实现。 + */ + +/** 结构化产物(A2A Artifact 的最小形态) */ +export interface AgentArtifact { + /** 产物名(对应管线端口名) */ + name: string; + /** 产物类型(如 MRContext / Findings / Patch / KnowledgeItem) */ + type: string; + /** 文本载荷(二进制产物先不支撑,后续以引用/URI 扩展) */ + content: string; +} + +/** 任务信封(A2A Task 的最小形态) */ +export interface TaskEnvelope { + /** 任务 id(管线 runId:nodeId 派生) */ + id: string; + /** 能力标识(如 code-review / security-scan),供对端路由 */ + capability: string; + /** 管线端口产物(原始结构) */ + input: Record; + /** 结构化产物清单(input 的展平视图,便于非 JSON 对端消费) */ + artifacts: AgentArtifact[]; + /** 超时(毫秒),缺省由传输层决定 */ + timeoutMs?: number; +} + +/** 任务结果(A2A Task 终态的最小形态) */ +export interface TaskResult { + status: 'completed' | 'failed'; + /** + * 返回给管线的端口产物:键名必须与下游边的 from.port 一致才会被递送, + * 写错的键会被静默丢弃(executor.gatherInputs 按边定义取值)。 + */ + output: Record; + artifacts: AgentArtifact[]; + error?: string; +} + +/** + * Agent 能力描述(A2A AgentCard 的最小子集)。 + * params 中的连接信息(endpoint/command/serverUrl)不属于卡片语义,由注册表另行持有。 + */ +export interface AgentCapabilityCard { + id: string; + name: string; + description?: string; + /** 声明的能力标识列表(对应 TaskEnvelope.capability) */ + capabilities: string[]; + transport: 'a2a' | 'subprocess' | 'mcp'; +} diff --git a/src/advance/agents/transports.ts b/src/advance/agents/transports.ts new file mode 100644 index 0000000..0817109 --- /dev/null +++ b/src/advance/agents/transports.ts @@ -0,0 +1,256 @@ +/** + * 外部 Agent 传输适配器。 + * + * 三种传输: + * - subprocess:本地 CLI Agent(stdin 收 JSON 信封,stdout 回 JSON 结果) + * - a2a:远程 A2A 风格 HTTP Agent(POST /tasks;GET /.well-known/agent.json 探测)。 + * 注意:这是 A2A 词汇的最小映射,**不与现网完整 A2A 协议互通** + * (真实 A2A 走 JSON-RPC message/send/tasks/get),完整适配留给后续里程碑。 + * - mcp:MCP 工具调用(SSE transport) + * + * 约束(RULES):subprocess 的 command 必须是可执行文件(Windows 上 .cmd/.bat + * 封装需经 shell,请注册包装脚本);TaskResult 经 zod 校验,非法形状一律 failed。 + */ + +import { spawn } from 'node:child_process'; +import { z } from 'zod'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import type { AgentCapabilityCard, TaskEnvelope, TaskResult } from './task-envelope.js'; + +export interface AgentTransport { + readonly kind: AgentCapabilityCard['transport']; + /** 健康探测(不执行任务) */ + probe(): Promise; + execute(envelope: TaskEnvelope): Promise; +} + +const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; +const PROBE_TIMEOUT_MS = 5_000; +/** stdout/stderr 累积上限,失控对端不致撑爆内存 */ +const MAX_OUTPUT_CHARS = 5 * 1024 * 1024; + +/** TaskResult 形状校验:对端返回垃圾时一律收敛为 failed */ +const taskResultSchema = z.object({ + status: z.enum(['completed', 'failed']), + output: z.record(z.unknown()).default({}), + artifacts: z + .array(z.object({ name: z.string(), type: z.string(), content: z.string() })) + .default([]), + error: z.string().optional(), +}); + +export function parseTaskResult(raw: unknown): TaskResult { + const parsed = taskResultSchema.safeParse(raw); + if (!parsed.success) { + return { status: 'failed', output: {}, artifacts: [], error: '对端返回的 TaskResult 形状非法' }; + } + return parsed.data; +} + +function failure(error: unknown): TaskResult { + const message = error instanceof Error ? error.message : String(error); + return { status: 'failed', output: {}, artifacts: [], error: message }; +} + +/** + * 本地子进程传输:适用于 OpenCode 类 CLI Agent 的脚本化接入。 + * 约定:信封 JSON 写 stdin,结果 JSON 从 stdout 读;进程退出码非零视为 failed。 + */ +export class SubprocessTransport implements AgentTransport { + readonly kind = 'subprocess' as const; + + constructor(private readonly spec: { command: string; args?: string[] }) {} + + async probe(): Promise { + return new Promise(resolve => { + const child = spawn(this.spec.command, [...(this.spec.args ?? []), '--version'], { + stdio: ['ignore', 'ignore', 'ignore'], + }); + const timer = setTimeout(() => { + child.kill('SIGKILL'); + resolve(false); + }, PROBE_TIMEOUT_MS); + child.on('error', () => { + clearTimeout(timer); + resolve(false); + }); + child.on('exit', code => { + clearTimeout(timer); + resolve(code === 0); + }); + }); + } + + async execute(envelope: TaskEnvelope): Promise { + const timeoutMs = envelope.timeoutMs ?? DEFAULT_TIMEOUT_MS; + return new Promise(resolve => { + const child = spawn(this.spec.command, this.spec.args ?? [], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let settled = false; + const settle = (result: TaskResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => { + child.kill('SIGKILL'); + settle(failure(`子进程执行超时(${timeoutMs}ms)`)); + }, timeoutMs); + + child.stdout.on('data', chunk => { + if (stdout.length < MAX_OUTPUT_CHARS) stdout += String(chunk); + }); + child.stderr.on('data', chunk => { + if (stderr.length < MAX_OUTPUT_CHARS) stderr += String(chunk); + }); + // 子进程早退不读 stdin 时,写入会触发 EPIPE——由 exit 路径统一收敛为 failed + child.stdin.on('error', () => undefined); + child.on('error', error => settle(failure(error))); + child.on('exit', code => { + if (code !== 0) { + settle(failure(`子进程退出码 ${code}: ${stderr.slice(-500)}`)); + return; + } + try { + settle(parseTaskResult(JSON.parse(stdout))); + } catch { + settle(failure(`子进程输出不是合法 TaskResult JSON: ${stdout.slice(-500)}`)); + } + }); + child.stdin.write(JSON.stringify(envelope)); + child.stdin.end(); + }); + } +} + +/** + * A2A 风格 HTTP 传输:远程第三方 Agent。 + * 约定(A2A 词汇的最小映射):GET /.well-known/agent.json 取能力卡片; + * POST /tasks 提交信封,返回任务结果。与完整 A2A 协议的映射留给后续适配。 + */ +export class A2aTransport implements AgentTransport { + readonly kind = 'a2a' as const; + private readonly endpoint: string; + + constructor(spec: { endpoint: string }) { + // 归一化尾部斜杠,避免 //tasks + this.endpoint = spec.endpoint.replace(/\/+$/, ''); + } + + async probe(): Promise { + try { + const response = await fetch(`${this.endpoint}/.well-known/agent.json`, { + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS * 2), + }); + return response.ok; + } catch { + return false; + } + } + + async execute(envelope: TaskEnvelope): Promise { + const timeoutMs = envelope.timeoutMs ?? DEFAULT_TIMEOUT_MS; + try { + const response = await fetch(`${this.endpoint}/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(envelope), + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + return failure(`A2A 端点 ${response.status}: ${(await response.text()).slice(-500)}`); + } + return parseTaskResult(await response.json()); + } catch (error) { + return failure(error); + } + } + + /** 拉取对端能力卡片(注册表探测/展示用) */ + async fetchCard(): Promise { + try { + const response = await fetch(`${this.endpoint}/.well-known/agent.json`, { + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS * 2), + }); + if (!response.ok) return null; + return (await response.json()) as AgentCapabilityCard; + } catch { + return null; + } + } +} + +/** MCP 工具传输:把任务信封作为 MCP 工具的入参调用 */ +export class McpTransport implements AgentTransport { + readonly kind = 'mcp' as const; + + constructor(private readonly spec: { serverUrl: string; tool: string }) {} + + async probe(): Promise { + const client = new Client({ name: 'codekeeper-agent-probe', version: '0.1.0' }); + try { + await client.connect(new SSEClientTransport(new URL('/sse', this.spec.serverUrl))); + const tools = await client.listTools(); + return tools.tools.some(tool => tool.name === this.spec.tool); + } catch { + return false; + } finally { + await client.close().catch(() => undefined); + } + } + + async execute(envelope: TaskEnvelope): Promise { + const timeoutMs = envelope.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const client = new Client({ name: 'codekeeper-agent-mcp', version: '0.1.0' }); + try { + const work = (async () => { + await client.connect(new SSEClientTransport(new URL('/sse', this.spec.serverUrl))); + const result = await client.callTool({ + name: this.spec.tool, + arguments: { envelope }, + }); + const text = (result.content as Array<{ type: string; text?: string }>) + .filter(part => part.type === 'text') + .map(part => part.text ?? '') + .join('\n'); + try { + return parseTaskResult(JSON.parse(text)); + } catch { + // 对端未回结构化结果:整体作为单产物返回 + return { + status: 'completed' as const, + output: { result: text }, + artifacts: [{ name: 'result', type: 'Text', content: text }], + }; + } + })(); + return await withTimeout(work, timeoutMs); + } catch (error) { + return failure(error); + } finally { + await client.close().catch(() => undefined); + } + } +} + +/** 超时就拒绝的包装(MCP 传输等原生不支持超时的路径用) */ +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`执行超时(${timeoutMs}ms)`)), timeoutMs); + promise.then( + value => { + clearTimeout(timer); + resolve(value); + }, + error => { + clearTimeout(timer); + reject(error); + } + ); + }); +} diff --git a/src/advance/archive/archive-path.ts b/src/advance/archive/archive-path.ts index f04da42..0ad877b 100755 --- a/src/advance/archive/archive-path.ts +++ b/src/advance/archive/archive-path.ts @@ -28,7 +28,13 @@ export function computeArchivePath(params: ComputeArchivePathParams): string { const ext = extname(safeName); const nameWithoutExt = basename(safeName, ext); while (existingPaths.has(archivePath.toLowerCase())) { - archivePath = join(archiveRoot, safeCategory, safeDocType, yearMonth, `${nameWithoutExt}_${counter}${ext}`); + archivePath = join( + archiveRoot, + safeCategory, + safeDocType, + yearMonth, + `${nameWithoutExt}_${counter}${ext}` + ); counter++; } } @@ -47,12 +53,13 @@ export function toFlaggedArchivePath(archivePath: string): string { } export function sanitizePathSegment(segment: string): string { - return segment - .toLowerCase() - .replace(/[^a-z0-9_-]/g, '-') - .replace(/^-+|-+$/g, '') - .replace(/-+/g, '-') - || 'unknown'; + return ( + segment + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/-+/g, '-') || 'unknown' + ); } export function sanitizeFileName(name: string): string { diff --git a/src/advance/archive/classifier.ts b/src/advance/archive/classifier.ts index 76b8234..2494a11 100755 --- a/src/advance/archive/classifier.ts +++ b/src/advance/archive/classifier.ts @@ -9,7 +9,7 @@ import { } from '../llm/prompts/classify-prompt'; import { extractMetadata, type FileMetadata } from './metadata-extractor'; import type { ClassificationResult } from '../types'; -import { logger } from '../../core/logger'; +import { logger } from '../core/logger'; export interface ClassifierOptions { /** 自定义分类列表 */ diff --git a/src/advance/archive/dedup-detector.ts b/src/advance/archive/dedup-detector.ts index bf11d32..ba10107 100755 --- a/src/advance/archive/dedup-detector.ts +++ b/src/advance/archive/dedup-detector.ts @@ -39,7 +39,7 @@ export class DedupDetector { const threshold = this.options.threshold ?? 0.7; // 快速路径:哈希完全相同的直接判定为重复 - const exactMatch = candidates.find((c) => c.contentHash === source.contentHash); + const exactMatch = candidates.find(c => c.contentHash === source.contentHash); if (exactMatch) { return { relation: 'duplicate', @@ -52,7 +52,11 @@ export class DedupDetector { // 按与 source 内容长度差异升序预排序,优先对比更可能相关的候选 const sorted = candidates .slice() - .sort((a, b) => Math.abs(a.content.length - source.content.length) - Math.abs(b.content.length - source.content.length)); + .sort( + (a, b) => + Math.abs(a.content.length - source.content.length) - + Math.abs(b.content.length - source.content.length) + ); const limited = sorted.slice(0, maxCandidates); let best: DedupResult = { relation: 'unrelated', reason: '未找到相关候选', confidence: 0 }; diff --git a/src/advance/archive/metadata-extractor.ts b/src/advance/archive/metadata-extractor.ts index a18af64..88e59c4 100755 --- a/src/advance/archive/metadata-extractor.ts +++ b/src/advance/archive/metadata-extractor.ts @@ -74,7 +74,10 @@ function readHeader(filePath: string, maxBytes: number): string { } } -function openFile(filePath: string): { read: (b: Buffer, o: number, l: number, p: number) => { bytesRead: number }; close: () => void } | null { +function openFile(filePath: string): { + read: (b: Buffer, o: number, l: number, p: number) => { bytesRead: number }; + close: () => void; +} | null { try { const fd = openSync(filePath, 'r'); return { @@ -92,8 +95,8 @@ function openFile(filePath: string): { read: (b: Buffer, o: number, l: number, p function tokenizePath(filePath: string): string[] { const parts = filePath.split(/[/\\_-]+/); return parts - .map((p) => p.toLowerCase().replace(/\.[^.]+$/, '')) - .filter((p) => p.length > 1 && !/^[0-9]+$/.test(p)); + .map(p => p.toLowerCase().replace(/\.[^.]+$/, '')) + .filter(p => p.length > 1 && !/^[0-9]+$/.test(p)); } function extractDateHints(fileName: string, _tokens: string[]): string[] { @@ -123,10 +126,18 @@ function classifyByPath(filePath: string, fileName: string, _tokens: string[]): if (lowerPath.includes('weekly') || lowerName.includes('weekly')) { return { category: 'weekly', docType: 'weekly', confidence: 0.9 }; } - if (lowerPath.includes('/docs/design') || lowerPath.includes('\\docs\\design') || lowerPath.includes('/design/')) { + if ( + lowerPath.includes('/docs/design') || + lowerPath.includes('\\docs\\design') || + lowerPath.includes('/design/') + ) { return { category: 'design', docType: 'design', confidence: 0.85 }; } - if (lowerPath.includes('/docs/spec') || lowerPath.includes('\\docs\\spec') || lowerName.includes('spec')) { + if ( + lowerPath.includes('/docs/spec') || + lowerPath.includes('\\docs\\spec') || + lowerName.includes('spec') + ) { return { category: 'design', docType: 'spec', confidence: 0.8 }; } if (lowerPath.includes('memory')) { diff --git a/src/advance/archive/suggestion-engine.ts b/src/advance/archive/suggestion-engine.ts index 763940e..4b86b79 100755 --- a/src/advance/archive/suggestion-engine.ts +++ b/src/advance/archive/suggestion-engine.ts @@ -4,7 +4,7 @@ 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 { logger } from '../core/logger'; import { toFlaggedArchivePath } from './archive-path'; const SUGGEST_RESPONSE_SCHEMA: ToolDefinition['input_schema'] = { diff --git a/src/advance/archive/undo-executor.ts b/src/advance/archive/undo-executor.ts index f181bd2..8b30aa8 100755 --- a/src/advance/archive/undo-executor.ts +++ b/src/advance/archive/undo-executor.ts @@ -12,7 +12,7 @@ export interface UndoResult { export class UndoExecutor { constructor(private options: { store: MetadataStore }) {} - async undo(actionId: string): Promise { + async undo(actionId: string, projectId?: string): Promise { const history = this.options.store.getActionHistory(actionId); if (!history) { return { success: false, message: '未找到动作历史记录' }; @@ -20,6 +20,9 @@ export class UndoExecutor { if (history.status === 'undone') { return { success: false, message: '该动作已被撤销' }; } + if (projectId && history.projectId !== projectId) { + return { success: false, message: '动作不属于当前项目' }; + } switch (history.type) { case 'copy': @@ -33,10 +36,12 @@ export class UndoExecutor { if (!history.targetPath) { return { success: false, message: 'organize 动作缺少目标路径' }; } - const previous = this.options.store.getArchiveMetadata(history.id)?.archivePath; - if (previous && existsSync(history.targetPath)) { - renameSync(history.targetPath, previous); + // action_history.source_path 是 organize 前的原位置,target_path 是新位置。 + // 不能用 action id 查询 archive_metadata:metadata.entry_id 使用的是源文件路径。 + if (!existsSync(history.targetPath)) { + return { success: false, message: 'organize 目标文件不存在,无法恢复原位置' }; } + renameSync(history.targetPath, history.sourcePath); break; } case 'ignore': { diff --git a/src/advance/archiver/codegraph-client.ts b/src/advance/archiver/codegraph-client.ts index a77a451..b5b35dd 100644 --- a/src/advance/archiver/codegraph-client.ts +++ b/src/advance/archiver/codegraph-client.ts @@ -138,7 +138,8 @@ export class CodeGraphClient implements ArchiverProviderCoordinator { }); const payload = (await response.json()) as CodeGraphRpcResponse; if (!response.ok || payload.ok !== true) { - const detail = typeof payload.error === 'string' ? payload.error : `HTTP ${response.status}`; + const detail = + typeof payload.error === 'string' ? payload.error : `HTTP ${response.status}`; throw new Error(`CodeGraph Server 请求失败:${detail}`); } return payload.result as T; diff --git a/src/advance/archiver/codegraph-service.ts b/src/advance/archiver/codegraph-service.ts index d0f4c9f..7942a2b 100644 --- a/src/advance/archiver/codegraph-service.ts +++ b/src/advance/archiver/codegraph-service.ts @@ -4,7 +4,7 @@ import http, { type ServerResponse, } from 'node:http'; import { URL } from 'node:url'; -import { logger } from '../../core/logger.js'; +import { logger } from '../core/logger.js'; import type { CodeGraphProviderStatus, CodeGraphServiceStatus, @@ -265,10 +265,7 @@ export class CodeGraphService implements CodeGraphServiceController { ); } - private clearSyncJob( - projectId: string, - job: Promise - ): void { + private clearSyncJob(projectId: string, job: Promise): void { if (this.syncJobs.get(projectId) === job) this.syncJobs.delete(projectId); } @@ -279,9 +276,7 @@ export class CodeGraphService implements CodeGraphServiceController { } } - private initialProviderStatus( - descriptor: ArchiverProviderDescriptor - ): CodeGraphProviderStatus { + private initialProviderStatus(descriptor: ArchiverProviderDescriptor): CodeGraphProviderStatus { if (descriptor.kind === 'builtin') { return { providerId: descriptor.id, @@ -308,10 +303,7 @@ export class CodeGraphService implements CodeGraphServiceController { state: descriptor.automation === 'manual' ? 'manual' : 'unavailable', prepared: false, version: null, - message: - descriptor.automation === 'manual' - ? '需要 Agent 工作流调度' - : '未声明可用运行时', + message: descriptor.automation === 'manual' ? '需要 Agent 工作流调度' : '未声明可用运行时', }; } @@ -413,10 +405,7 @@ export class CodeGraphService implements CodeGraphServiceController { } } - private async handleRequest( - request: IncomingMessage, - response: ServerResponse - ): Promise { + 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() }); diff --git a/src/advance/archiver/managed-provider-runtime.ts b/src/advance/archiver/managed-provider-runtime.ts index 3742ee0..e01163b 100644 --- a/src/advance/archiver/managed-provider-runtime.ts +++ b/src/advance/archiver/managed-provider-runtime.ts @@ -1,6 +1,6 @@ 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 { getAppStorageDir } from '../core/platform.js'; import type { ArchiverProviderDescriptor, ArchiverProviderManagedInvocation, diff --git a/src/advance/archiver/provider-config.ts b/src/advance/archiver/provider-config.ts index fbab633..bcd7d4e 100644 --- a/src/advance/archiver/provider-config.ts +++ b/src/advance/archiver/provider-config.ts @@ -66,10 +66,7 @@ export function normalizeArchiverProviderStrategy(value: unknown): ArchiverProvi raw.synthesis && typeof raw.synthesis === 'object' ? (raw.synthesis as Record) : undefined; - const synthesisProvider = normalizeText( - synthesisRaw?.provider, - defaults.synthesis.provider - ); + 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( @@ -113,8 +110,7 @@ export function toArchiverProviderExecutionStrategy( fallbacks: sources.slice(1), enrichers, builtinFallback: - synthesisProvider === BUILTIN_ARCHIVER_PROVIDER_ID && - knowledge.synthesis.policy !== 'off', + synthesisProvider === BUILTIN_ARCHIVER_PROVIDER_ID && knowledge.synthesis.policy !== 'off', overrides: {}, }; } @@ -144,11 +140,8 @@ export function normalizeArchiverConfig(value: unknown): ArchiverConfig { enabled: typeof automation.enabled === 'boolean' ? automation.enabled - : legacyEnabled ?? defaults.automation.enabled, - cron: normalizeText( - automation.cron, - normalizeText(legacyCron, defaults.automation.cron) - ), + : (legacyEnabled ?? defaults.automation.enabled), + cron: normalizeText(automation.cron, normalizeText(legacyCron, defaults.automation.cron)), }, }; } diff --git a/src/advance/archiver/provider-orchestrator.ts b/src/advance/archiver/provider-orchestrator.ts index dbe2f7f..9e5e514 100644 --- a/src/advance/archiver/provider-orchestrator.ts +++ b/src/advance/archiver/provider-orchestrator.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { logger } from '../../core/logger.js'; +import { logger } from '../core/logger.js'; import type { Project } from '../types.js'; import { BUILTIN_ARCHIVER_PROVIDER_ID } from './provider-config.js'; import { @@ -466,7 +466,8 @@ export class ArchiverProviderOrchestrator implements ArchiverProviderCoordinator const passiveProviderIds = this.registry .listDescriptors() .filter( - descriptor => descriptor.automation === 'manual' && descriptor.capabilities.includes('query') + descriptor => + descriptor.automation === 'manual' && descriptor.capabilities.includes('query') ) .map(descriptor => descriptor.id); const knowledgeProviderIds = uniqueIds([...providerIds, ...passiveProviderIds]); diff --git a/src/advance/ast-grep/runner.ts b/src/advance/ast-grep/runner.ts new file mode 100644 index 0000000..74770d4 --- /dev/null +++ b/src/advance/ast-grep/runner.ts @@ -0,0 +1,99 @@ +/** + * ast-grep 预检(M7 以 stage 形态回归)。 + * + * 作为管线 stage 节点的执行体:对给定文件列表运行 ast-grep 规则扫描, + * 产出结构化预检发现(advisory,供评审/记忆参考,不阻断)。 + * ast-grep 二进制缺失时优雅降级(skipped)。 + */ + +import { existsSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { logger } from '../core/logger.js'; + +export interface AstGrepPrecheckFinding { + file: string; + line: number; + column: number; + ruleId: string; + message: string; + severity: string; +} + +export interface AstGrepPrecheckResult { + skipped: boolean; + reason?: string; + findings: AstGrepPrecheckFinding[]; +} + +const SCAN_TIMEOUT_MS = 30_000; + +/** + * 对文件列表执行 ast-grep 扫描。 + * configPath:sgconfig.yml 路径;filePaths:待扫描文件(不存在的自动过滤)。 + */ +export async function runAstGrepPrecheck( + configPath: string, + filePaths: string[] +): Promise { + const existing = filePaths.filter(file => existsSync(file)); + if (existing.length === 0) { + return { skipped: true, reason: '没有可扫描的文件', findings: [] }; + } + if (!existsSync(configPath)) { + return { skipped: true, reason: `ast-grep 配置不存在: ${configPath}`, findings: [] }; + } + + return new Promise(resolve => { + // 数组参数不经 shell,避免路径注入。 + // 注意:Windows 上无 shell 的 spawn 解析不到 ast-grep.cmd shim,会走优雅降级(skipped)。 + const child = spawn('ast-grep', ['scan', '--config', configPath, '--json', ...existing], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { + child.kill('SIGKILL'); + resolve({ skipped: true, reason: 'ast-grep 扫描超时', findings: [] }); + }, SCAN_TIMEOUT_MS); + + child.stdout.on('data', chunk => (stdout += String(chunk))); + child.stderr.on('data', chunk => (stderr += String(chunk))); + child.on('error', () => { + clearTimeout(timer); + resolve({ skipped: true, reason: 'ast-grep 不可用(未安装)', findings: [] }); + }); + child.on('exit', () => { + // ast-grep 有发现时返回非零退出码,stdout 仍是合法 JSON + clearTimeout(timer); + const findings = parseAstGrepOutput(stdout); + if (findings.length === 0 && stderr.trim()) { + logger.warn({ stderr: stderr.slice(-200) }, '[AstGrep] 扫描异常输出'); + } + resolve({ skipped: false, findings }); + }); + }); +} + +/** 解析 ast-grep JSON 输出(兼容数组与 {results} 包装) */ +export function parseAstGrepOutput(output: string): AstGrepPrecheckFinding[] { + try { + const parsed: unknown = JSON.parse(output); + const entries = Array.isArray(parsed) + ? parsed + : ((parsed as { results?: unknown[] }).results ?? []); + return (Array.isArray(entries) ? entries : []).flatMap((entry: Record) => { + const file = String(entry.file ?? ''); + const rules = Array.isArray(entry.rules) ? entry.rules : [entry]; + return (rules as Array>).map(rule => ({ + file, + line: Number(rule.line ?? 0), + column: Number(rule.column ?? 0), + ruleId: String(rule.id ?? rule.ruleId ?? 'unknown'), + message: String(rule.message ?? rule.text ?? ''), + severity: String(rule.severity ?? rule.level ?? 'warning'), + })); + }); + } catch { + return []; + } +} diff --git a/src/advance/classic/agent-entries/role-entry.ts b/src/advance/classic/agent-entries/role-entry.ts index 5529057..68da34a 100755 --- a/src/advance/classic/agent-entries/role-entry.ts +++ b/src/advance/classic/agent-entries/role-entry.ts @@ -2,12 +2,26 @@ import { createRoleRunner } from '../runners/role-runner.js'; import type { Role, Project } from '../../types.js'; import { LlmClient } from '../../llm/client.js'; import { MetadataStore } from '../../store/metadata-store.js'; +import { PipelineExecutor } from '../../pipeline/core/executor.js'; +import { PipelineRunStore } from '../../pipeline/core/run-store.js'; +import type { PipelineDefinitionWithSubgraph } from '../../pipeline/core/types.js'; +import { loadProjectPipeline } from '../../pipeline/default-pipeline.js'; +import { createRoleStageHandlers } from '../stage-handlers.js'; + +/** 项目管线中正本中本角色节点的子图(无子图返回 null,保持黑箱执行) */ +function loadRoleSubgraph(project: Project, role: Role): PipelineDefinitionWithSubgraph | null { + const definition = loadProjectPipeline(project); + if (!definition) return null; + const roleNode = definition.nodes.find(node => node.type === `role.${role}`); + return (roleNode?.subgraph as PipelineDefinitionWithSubgraph | undefined) ?? null; +} /** * 从环境变量解析 Agent 配置 */ export function loadConfigFromEnv(env: NodeJS.ProcessEnv): { role: Role; + projectId: string; dbPath: string; llm: { apiKey: string; @@ -17,9 +31,9 @@ export function loadConfigFromEnv(env: NodeJS.ProcessEnv): { headers: string; rpm: number; }; - projects: Project[]; } { const role = env.ROLE as Role; + const projectId = env.CK_PROJECT_ID ?? ''; const dbPath = env.CK_DB_PATH ?? ''; const apiKey = env.CK_LLM_API_KEY ?? ''; const provider = env.CK_LLM_PROVIDER ?? ''; @@ -27,31 +41,30 @@ export function loadConfigFromEnv(env: NodeJS.ProcessEnv): { const apiUrl = env.CK_LLM_API_URL ?? ''; const headers = env.CK_LLM_HEADERS ?? '{}'; const rpm = Number(env.CK_LLM_RPM ?? '10'); - const projects = parseProjectsJson(env.CK_PROJECTS_JSON); + if (!role) { + throw new Error('缺少 ROLE 环境变量'); + } + if (!projectId) { + throw new Error('缺少 CK_PROJECT_ID 环境变量'); + } + if (!dbPath) { + throw new Error('缺少 CK_DB_PATH 环境变量'); + } if (!apiKey || !provider || !model || !apiUrl) { - throw new Error('缺少必要的环境变量'); + throw new Error( + '缺少必要的环境变量:CK_LLM_API_KEY, CK_LLM_PROVIDER, CK_LLM_MODEL, CK_LLM_API_URL' + ); } return { role, + projectId, dbPath, llm: { apiKey, provider, model, apiUrl, headers, rpm }, - projects, }; } -function parseProjectsJson(raw?: string): Project[] { - if (!raw || raw.trim() === '') return []; - try { - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - return parsed as Project[]; - } catch { - throw new Error('CK_PROJECTS_JSON 解析失败'); - } -} - /** * 根据每分钟请求数计算最小请求间隔(毫秒) */ @@ -61,30 +74,15 @@ function computeMinRequestInterval(rpm: number): number { } /** - * 统一角色 Agent Entry - * 作为独立子进程运行,周期性读取数据库中该角色的启用项目, - * 动态启动/停止对应项目的 Agent 循环。 + * Role 节点实例入口(子进程)。 + * + * 一个进程只服务一个 (项目, 角色) 节点实例:启动时加载项目并构造 Runner, + * 之后等待父进程(RoleNodeRuntime)经 IPC 下发的 run 指令执行单次循环。 + * 调度由 daemon 侧 PipelineScheduler(trigger.cron 节点)负责,本进程不再自持 cron。 */ async function main() { const config = loadConfigFromEnv(process.env); - - console.log(`[Role Agent] 启动,ROLE=${config.role}`); - if (!config.role) { - throw new Error('缺少 ROLE 环境变量'); - } - if (!config.dbPath) { - throw new Error('缺少 CK_DB_PATH 环境变量'); - } - - console.log(`[Role Agent] 数据库路径: ${config.dbPath}`); - console.log( - `[Role Agent] LLM 配置检查: provider=${config.llm.provider ? '有' : '无'}, model=${config.llm.model ? '有' : '无'}, apiUrl=${config.llm.apiUrl ? '有' : '无'}, apiKey=${config.llm.apiKey ? '有' : '无'}, rpm=${config.llm.rpm}` - ); - if (!config.llm.apiKey || !config.llm.provider || !config.llm.model || !config.llm.apiUrl) { - throw new Error( - '[Role Agent] 缺少必要的环境变量:CK_LLM_API_KEY, CK_LLM_PROVIDER, CK_LLM_MODEL, CK_LLM_API_URL' - ); - } + console.log(`[Role Node] 启动,ROLE=${config.role},项目=${config.projectId}`); // 解析额外请求头(空字符串按空对象处理) let headers: Record = {}; @@ -92,12 +90,11 @@ async function main() { try { headers = JSON.parse(config.llm.headers) as Record; } catch { - console.warn('[Role Agent] CK_LLM_HEADERS 解析失败,使用空对象'); + console.warn('[Role Node] CK_LLM_HEADERS 解析失败,使用空对象'); } } const minRequestInterval = computeMinRequestInterval(config.llm.rpm); - console.log(`[Role Agent] LLM 最小请求间隔: ${minRequestInterval}ms`); const llmClient = new LlmClient({ apiKey: config.llm.apiKey, @@ -115,67 +112,58 @@ async function main() { codeGraphUrl: process.env.CK_CODEGRAPH_SERVER_URL, }); - // 子进程独立打开数据库,周期性读取启用项目并同步 Agent 循环 const store = new MetadataStore(config.dbPath); + const project = store.getProject(config.projectId); + if (!project) { + throw new Error(`[Role Node] 项目不存在: ${config.projectId}`); + } - const activeProjects = new Map< - string, - { project: { id: string; rootPath: string; name: string } } - >(); - - let syncing = false; - - const syncProjects = async () => { - if (syncing) return; - syncing = true; - try { - const enabledProjects = store.getRoleEnabledProjects(config.role); - const enabledIds = new Set(enabledProjects.map((p) => p.id)); - - // 停止已禁用或已移除的项目 - for (const [projectId, entry] of activeProjects) { - if (!enabledIds.has(projectId)) { - console.log(`[Role Agent] 项目 ${entry.project.name} 已禁用,停止循环`); - runner.stopProjectLoop(projectId); - activeProjects.delete(projectId); - } - } - - // 启动新启用的项目 - for (const project of enabledProjects) { - if (!activeProjects.has(project.id)) { - console.log(`[Role Agent] 项目 ${project.name} 已启用,启动循环`); - activeProjects.set(project.id, { project }); - // 异步启动,避免阻塞本次同步 - runner.startProjectLoop(project).catch((err) => { - console.error(`[Role Agent] 项目 ${project.name} 启动循环失败:`, err); - activeProjects.delete(project.id); - }); - } - } - } catch (err) { - console.error('[Role Agent] 同步启用项目失败:', err); - } finally { - syncing = false; - } - }; - - // 立即同步一次,然后每 10 秒轮询 - await syncProjects(); - const intervalId = setInterval(() => { - void syncProjects(); - }, 10000); + // 钻取层:项目的管线正本中,本角色节点若带 subgraph,则执行子图而非整轮黑箱 + const roleSubgraph = loadRoleSubgraph(project, config.role); + const stageHandlers = roleSubgraph ? createRoleStageHandlers({ project, runner }) : null; + // 子图执行落库(stage 记录按子图节点命名,画布运行状态叠加可查) + const subgraphExecutor = stageHandlers + ? new PipelineExecutor(stageHandlers, new PipelineRunStore(store.database)) + : null; + + // 指令循环:等待父进程派发 run + process.on('message', message => { + if (!isRunMessage(message)) return; + const work = + subgraphExecutor && roleSubgraph + ? subgraphExecutor + .execute( + roleSubgraph, + { + logger: console, + services: { project }, + vars: { projectId: project.id }, + }, + { projectId: project.id } + ) + .then(record => { + if (record.status !== 'succeeded') { + throw new Error(record.error ?? '子图执行失败'); + } + }) + : runner.runProjectOnce(project); + work + .then(() => { + process.send?.({ type: 'done' }); + }) + .catch(error => { + const text = error instanceof Error ? error.message : String(error); + console.error(`[Role Node] 单次执行失败: ${text}`); + process.send?.({ type: 'error', message: text }); + }); + }); - console.log('[Role Agent] 监控服务已启动,每 10 秒检查一次项目启用状态'); + // 就绪信号:父进程据此开始派发 + process.send?.({ type: 'ready' }); + console.log('[Role Node] 已就绪,等待触发指令'); - // 优雅退出:收到信号后停止所有循环并关闭数据库 + // 优雅退出 const cleanup = () => { - console.log('[Role Agent] 收到退出信号,停止监控'); - clearInterval(intervalId); - for (const [projectId] of activeProjects) { - runner.stopProjectLoop(projectId); - } - activeProjects.clear(); store.close(); process.exit(0); }; @@ -183,12 +171,20 @@ async function main() { process.on('SIGINT', cleanup); } +function isRunMessage(message: unknown): boolean { + return ( + typeof message === 'object' && + message !== null && + (message as { type?: unknown }).type === 'run' + ); +} + // 仅当直接运行时执行主函数(子进程入口) const isMainModule = process.argv[1] && (process.argv[1].endsWith('role-entry.ts') || process.argv[1].endsWith('role-entry.js')); if (isMainModule) { - main().catch((err) => { + main().catch(err => { console.error(err); process.exit(1); }); diff --git a/src/advance/classic/classic-config-builder.ts b/src/advance/classic/classic-config-builder.ts deleted file mode 100755 index 38ca43c..0000000 --- a/src/advance/classic/classic-config-builder.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { Project } from '../types.js'; - -/** - * MR Agent 子进程所需的环境变量接口 - * - * 由 ClassicService 通过 buildMrAgentEnv 构造后传入子进程, - * mr-agent-entry.ts 中的 loadConfigFromEnv 负责解析。 - */ -export interface MrAgentEnv { - /** LLM API Key */ - CK_LLM_API_KEY: string; - /** LLM 提供商 */ - CK_LLM_PROVIDER: string; - /** LLM 模型名 */ - CK_LLM_MODEL: string; - /** LLM API 基础 URL */ - CK_LLM_API_URL: string; - /** 额外请求头(JSON 字符串) */ - CK_LLM_HEADERS: string; - /** 启用 MR 评审的项目列表(JSON 字符串) */ - CK_PROJECTS_JSON: string; -} - -/** - * 将 advance 的 daemon 配置和项目列表转换为 MR Agent 环境变量 - * - * 只保留同时满足以下条件的项目: - * 1. 启用了 MR 评审(mrReview.enabled === true) - * 2. 配置了 GitLab(gitlab 字段存在) - * - * 过滤后的项目列表以 JSON 字符串形式写入 CK_PROJECTS_JSON, - * 子进程反序列化后可直接使用。 - */ -export function buildMrAgentEnv( - projects: Project[], - daemonConfig: { - apiKey: string; - provider: string; - model: string; - apiUrl: string; - headers: string; - } -): MrAgentEnv { - const mrProjects = projects.filter((p) => p.mrReview?.enabled && p.gitlab); - return { - CK_LLM_API_KEY: daemonConfig.apiKey, - CK_LLM_PROVIDER: daemonConfig.provider, - CK_LLM_MODEL: daemonConfig.model, - CK_LLM_API_URL: daemonConfig.apiUrl, - CK_LLM_HEADERS: daemonConfig.headers, - CK_PROJECTS_JSON: JSON.stringify(mrProjects), - }; -} diff --git a/src/advance/classic/context/project-context-loader.ts b/src/advance/classic/context/project-context-loader.ts index 20722d5..4eeac54 100755 --- a/src/advance/classic/context/project-context-loader.ts +++ b/src/advance/classic/context/project-context-loader.ts @@ -9,10 +9,7 @@ const DEFAULT_MAX_LENGTH = 3000; * 默认读取归档目录中的 context.md,截取前 N 个字符作为 prompt 上下文。 * 若文件不存在或为空,返回空字符串。 */ -export function loadProjectContext( - archiveRoot: string, - maxLength = DEFAULT_MAX_LENGTH -): string { +export function loadProjectContext(archiveRoot: string, maxLength = DEFAULT_MAX_LENGTH): string { const path = join(archiveRoot, 'context.md'); if (!existsSync(path)) { return ''; diff --git a/src/advance/classic/fix/batch-fix-planner.ts b/src/advance/classic/fix/batch-fix-planner.ts index 485d9d9..4cca228 100755 --- a/src/advance/classic/fix/batch-fix-planner.ts +++ b/src/advance/classic/fix/batch-fix-planner.ts @@ -64,10 +64,7 @@ export class BatchFixPlanner { .join('\n\n'); const fileSections = Object.entries(input.fileContents) - .map( - ([path, content]) => - `## 文件:${path}\n\`\`\`\n${content}\n\`\`\`` - ) + .map(([path, content]) => `## 文件:${path}\n\`\`\`\n${content}\n\`\`\``) .join('\n\n'); return this.promptLoader.load('batch-fix-plan', { @@ -86,11 +83,14 @@ export class BatchFixPlanner { }; const patches = (parsed.patches ?? []) - .filter((p): p is { filePath: string; patch: string } => - typeof p.filePath === 'string' && p.filePath.length > 0 && - typeof p.patch === 'string' && p.patch.length > 0 + .filter( + (p): p is { filePath: string; patch: string } => + typeof p.filePath === 'string' && + p.filePath.length > 0 && + typeof p.patch === 'string' && + p.patch.length > 0 ) - .map((p) => ({ + .map(p => ({ filePath: p.filePath.replace(/\\/g, '/'), patch: p.patch, })); diff --git a/src/advance/classic/fix/cross-file-planner.ts b/src/advance/classic/fix/cross-file-planner.ts index 0aa4645..3c04940 100755 --- a/src/advance/classic/fix/cross-file-planner.ts +++ b/src/advance/classic/fix/cross-file-planner.ts @@ -67,7 +67,7 @@ export class CrossFilePlanner { .filter((p): p is { filePath: string; description: string } => Boolean(p.filePath && p.description) ) - .map((p) => ({ + .map(p => ({ filePath: p.filePath, description: p.description, })); diff --git a/src/advance/classic/fix/focused-context-builder.ts b/src/advance/classic/fix/focused-context-builder.ts index e330efb..1b216df 100755 --- a/src/advance/classic/fix/focused-context-builder.ts +++ b/src/advance/classic/fix/focused-context-builder.ts @@ -130,7 +130,7 @@ function isImportOrRequire(line: string): boolean { return ( trimmed.startsWith('import ') || trimmed.startsWith('require(') || - trimmed.startsWith('const ') && trimmed.includes(' = require(') + (trimmed.startsWith('const ') && trimmed.includes(' = require(')) ); } diff --git a/src/advance/classic/fix/issue-scope.ts b/src/advance/classic/fix/issue-scope.ts index 1c298d2..317347b 100755 --- a/src/advance/classic/fix/issue-scope.ts +++ b/src/advance/classic/fix/issue-scope.ts @@ -8,6 +8,7 @@ import type { LlmClient } from '../../llm/client.js'; import type { ReviewFinding } from '../provider/types.js'; import type { FocusedContext } from './focused-context-builder.js'; +import type { MaintainerLocalJudge } from './maintainer-local-judge.js'; import { extractJsonText } from '../utils/json-extraction.js'; import { defaultPromptLoader, type PromptLoader } from '../../llm/prompts/loader.js'; @@ -23,11 +24,14 @@ export interface ScopeClassifierOptions { llmClient?: LlmClient; /** 是否启用 LLM 二次确认;默认 false,避免每个 finding 都调用 LLM */ enableLlmConfirm?: boolean; + /** 可选的轻量判别辅助,用于启发式规则未命中时的 scope 初筛 */ + localJudge?: MaintainerLocalJudge; /** 可选的 prompt 加载器,默认使用全局 loader */ promptLoader?: PromptLoader; } -const CONTROL_FLOW_KEYWORDS = /\b(if|else|for|while|switch|case|try|catch|finally|return|throw|async|await|function|class|interface|type)\b/; +const CONTROL_FLOW_KEYWORDS = + /\b(if|else|for|while|switch|case|try|catch|finally|return|throw|async|await|function|class|interface|type)\b/; export class IssueScopeClassifier { private readonly promptLoader: PromptLoader; @@ -42,6 +46,22 @@ export class IssueScopeClassifier { return heuristic; } + if (this.options.localJudge && typeof this.options.localJudge.preFilterScope === 'function') { + const verdict = await this.options.localJudge.preFilterScope( + finding.message, + finding.file, + finding.line + ); + if ('kind' in verdict && verdict.kind === 'reliable') { + if (verdict.scope !== 'local') { + return { + scope: verdict.scope, + reason: verdict.reason, + }; + } + } + } + if (this.options.enableLlmConfirm && this.options.llmClient) { return await this.confirmWithLlm(finding, context); } @@ -91,7 +111,8 @@ export class IssueScopeClassifier { if (/添加\s*todo|todo\s*注释|缺少\s*注释|加注释/.test(text)) return true; // 缓存环境变量或常量 if (/缓存\s*(环境变量|常量|变量值)|const\s+\w+\s*=\s*!!?process\.env/.test(text)) return true; - if (text.includes('缓存') && (text.includes('环境变量') || text.includes('process.env'))) return true; + if (text.includes('缓存') && (text.includes('环境变量') || text.includes('process.env'))) + return true; // 添加可选字段 / 单个字段补全 if (/添加\s*(可选的?)?\s*字段|加\s*(可选的?)?\s*字段|\?\s*:/.test(text)) return true; // 拼写 / 命名 @@ -109,7 +130,11 @@ export class IssueScopeClassifier { // 规则 ID 直接提示是文档/注释类 const ruleId = (finding.ruleId ?? '').toUpperCase(); - if (['TODO', 'DOCUMENTATION', 'COMMENT', 'CHANGE-MANAGEMENT', 'PERFORMANCE'].some((r) => ruleId.includes(r))) { + if ( + ['TODO', 'DOCUMENTATION', 'COMMENT', 'CHANGE-MANAGEMENT', 'PERFORMANCE'].some(r => + ruleId.includes(r) + ) + ) { return true; } @@ -123,7 +148,7 @@ export class IssueScopeClassifier { if (/多个文件|跨文件|cross.file/.test(text)) return true; const ruleId = (finding.ruleId ?? '').toUpperCase(); - if (['TYPE-SAFETY', 'API-COMPATIBILITY', 'BREAKING-CHANGE'].some((r) => ruleId.includes(r))) { + if (['TYPE-SAFETY', 'API-COMPATIBILITY', 'BREAKING-CHANGE'].some(r => ruleId.includes(r))) { return true; } diff --git a/src/advance/classic/fix/maintainer-brain.ts b/src/advance/classic/fix/maintainer-brain.ts index b68a260..faacced 100755 --- a/src/advance/classic/fix/maintainer-brain.ts +++ b/src/advance/classic/fix/maintainer-brain.ts @@ -2,6 +2,7 @@ import type { ReviewFinding } from '../provider/types.js'; import { LlmClient, LlmDecisionError } from '../../llm/client.js'; import type { ToolDefinition } from '../../llm/tool-types.js'; import type { IMemoryClient } from '../memory/types.js'; +import type { MaintainerLocalJudge } from './maintainer-local-judge.js'; import { IssueScopeClassifier, type IssueScope } from './issue-scope.js'; import { buildFocusedContext, type FocusedContext } from './focused-context-builder.js'; import { focusedContextToString } from './focused-context-streamer.js'; @@ -182,6 +183,8 @@ export interface MaintainerBrainOptions { worktreeManager?: WorktreeManager; /** 可选的 prompt 加载器,默认使用全局 loader */ promptLoader?: PromptLoader; + /** 可选的轻量判别辅助,用于非 finding 讨论的前置语义过滤 */ + localJudge?: MaintainerLocalJudge; } export interface ParseFindingsInput { @@ -425,6 +428,36 @@ export class MaintainerBrain { userId: string; mrContext?: MrContext; }): Promise { + if ( + this.options.localJudge && + typeof this.options.localJudge.preFilterNonFindingDiscussion === 'function' + ) { + const verdict = await this.options.localJudge.preFilterNonFindingDiscussion( + params.body, + undefined + ); + if ('kind' in verdict && verdict.kind === 'reliable') { + if (verdict.isProbablyNonFinding) { + console.log( + `[LlmMaintainerLocalJudge] decideNonFindingComment preFilterNonFindingDiscussion=reliable isProbablyNonFinding=true reason=${verdict.reason}` + ); + return { + action: 'ignore', + reason: `本地判别辅助认为该讨论很可能不是待逐条修复的代码问题: ${verdict.reason}`, + replyBody: '感谢 Review。', + }; + } else { + console.log( + `[LlmMaintainerLocalJudge] decideNonFindingComment preFilterNonFindingDiscussion=reliable isProbablyNonFinding=false reason=${verdict.reason}` + ); + } + } else { + console.log( + `[LlmMaintainerLocalJudge] decideNonFindingComment preFilterNonFindingDiscussion=unreliable reason=${verdict.reason}` + ); + } + } + const mrContextText = params.mrContext ? [ `标题:${params.mrContext.title}`, diff --git a/src/advance/classic/fix/maintainer-llm-judge.ts b/src/advance/classic/fix/maintainer-llm-judge.ts new file mode 100644 index 0000000..1e91ff3 --- /dev/null +++ b/src/advance/classic/fix/maintainer-llm-judge.ts @@ -0,0 +1,398 @@ +/** * 基于 LlmClient 的轻量判别辅助实现 * * 目标: * - 在少数高杠杆语义判断点提供结构化辅助判别 * - 使用现有的 LlmClient,调用小 prompt、小输出 * - 不可靠/失败时回退为不可靠,不改变 Maintainer 既有行为 */ + +import { LlmClient } from '../../llm/client.js'; +import type { + MaintainerLocalJudge, + LocalJudgeVerdict, + SemanticReidentificationResult, + StuckCorrectionResult, + AlreadyFixedAssistanceResult, + PreFilterScopeVerdict, + PreFilterNonFindingVerdict, +} from './maintainer-local-judge.js'; + +interface ReidentifyPromptPayload { + currentDescription: string; + previousDecisionSummary: string; + fileContextHint?: string; +} + +interface StuckPromptPayload { + findingDescription: string; + recentProgressSummary: string; + attemptedDirectionsSummary?: string; +} + +interface AlreadyFixedPromptPayload { + findingDescription: string; + currentCodeContextHint?: string; +} + +interface PreFilterScopePromptPayload { + findingDescription: string; + findingFile?: string; + findingLine?: number; +} + +interface PreFilterNonFindingPromptPayload { + discussionBody: string; + discussionNoteCount?: number; +} + +/** * 基于 LlmClient 的 Maintainer 判别辅助实现 */ export class LlmMaintainerLocalJudge implements MaintainerLocalJudge { + constructor(private llmClient: LlmClient) {} + + isAvailable(): boolean { + return true; + } + + async reassessSemanticIdentity( + currentFindingDescription: string, + previousDecisionSummary: string, + fileContextHint?: string + ): Promise { + const payload: ReidentifyPromptPayload = { + currentDescription: currentFindingDescription, + previousDecisionSummary, + fileContextHint, + }; + try { + const json = await this.llmClient.completeJson( + this.buildReidentifyPrompt(payload), + this.reidentifySystem(), + { + type: 'object', + properties: { + likelySame: { type: 'boolean' }, + confidence: { type: 'string', enum: ['high', 'medium', 'low'] }, + reason: { type: 'string' }, + }, + required: ['likelySame', 'confidence', 'reason'], + } + ); + const body = this.parseSimpleJson(json); + if (!body || typeof body.likelySame !== 'boolean') { + return { kind: 'unreliable', reason: 'LLM 返回了不可解析的语义重识别结果' }; + } + const confidence = this.normalizeConfidence(body.confidence); + return { + likelySame: body.likelySame, + confidence, + reason: typeof body.reason === 'string' ? body.reason : '', + }; + } catch (error) { + return { kind: 'unreliable', reason: this.wrapError(error) }; + } + } + + async adviseOnStuckProgress( + findingDescription: string, + recentProgressSummary: string, + attemptedDirectionsSummary?: string + ): Promise { + const payload: StuckPromptPayload = { + findingDescription, + recentProgressSummary, + attemptedDirectionsSummary, + }; + try { + const json = await this.llmClient.completeJson( + this.buildStuckPrompt(payload), + this.stuckSystem(), + { + type: 'object', + properties: { + suggestion: { type: 'string', enum: ['continue', 'refocus', 'broaden', 'stop'] }, + suggestStop: { type: 'boolean' }, + reason: { type: 'string' }, + }, + required: ['suggestion', 'suggestStop', 'reason'], + } + ); + const body = this.parseSimpleJson(json); + if ( + !body || + typeof body.suggestion !== 'string' || + !['continue', 'refocus', 'broaden', 'stop'].includes(body.suggestion) + ) { + return { kind: 'unreliable', reason: 'LLM 返回了不可解析的卡点校正结果' }; + } + return { + suggestion: body.suggestion as StuckCorrectionResult['suggestion'], + suggestStop: Boolean(body.suggestStop), + reason: typeof body.reason === 'string' ? body.reason : '', + }; + } catch (error) { + return { kind: 'unreliable', reason: this.wrapError(error) }; + } + } + + async assistAlreadyFixedCheck( + findingDescription: string, + currentCodeContextHint?: string + ): Promise { + const payload: AlreadyFixedPromptPayload = { findingDescription, currentCodeContextHint }; + try { + const json = await this.llmClient.completeJson( + this.buildAlreadyFixedPrompt(payload), + this.alreadyFixedSystem(), + { + type: 'object', + properties: { + likelyAlreadyFixed: { type: 'boolean' }, + reason: { type: 'string' }, + evidence: { type: 'string' }, + }, + required: ['likelyAlreadyFixed', 'reason'], + } + ); + const body = this.parseSimpleJson(json); + if (!body || typeof body.likelyAlreadyFixed !== 'boolean') { + return { kind: 'unreliable', reason: 'LLM 返回了不可解析的 already-fixed 辅助结果' }; + } + return { + likelyAlreadyFixed: body.likelyAlreadyFixed, + reason: typeof body.reason === 'string' ? body.reason : '', + evidence: typeof body.evidence === 'string' ? body.evidence : undefined, + }; + } catch (error) { + return { kind: 'unreliable', reason: this.wrapError(error) }; + } + } + + async preFilterScope( + findingDescription: string, + findingFile?: string, + findingLine?: number + ): Promise { + const payload: PreFilterScopePromptPayload = { findingDescription, findingFile, findingLine }; + try { + const json = await this.llmClient.completeJson( + this.buildPreFilterScopePrompt(payload), + this.preFilterScopeSystem(), + { + type: 'object', + properties: { + scope: { + type: 'string', + enum: ['trivial', 'local', 'cross-file', 'needs-clarification'], + }, + reason: { type: 'string' }, + }, + required: ['scope', 'reason'], + } + ); + const body = this.parseSimpleJson(json); + if (!body || typeof body.scope !== 'string') { + return { kind: 'unreliable', reason: 'LLM 返回了不可解析的 scope 初筛结果' }; + } + if (!['trivial', 'local', 'cross-file', 'needs-clarification'].includes(body.scope)) { + return { kind: 'unreliable', reason: `LLM 返回了无效的 scope 值: ${String(body.scope)}` }; + } + return { + kind: 'reliable', + scope: body.scope as 'trivial' | 'local' | 'cross-file' | 'needs-clarification', + reason: typeof body.reason === 'string' ? body.reason : '', + }; + } catch (error) { + return { kind: 'unreliable', reason: this.wrapError(error) }; + } + } + + async preFilterNonFindingDiscussion( + discussionBody: string, + discussionNoteCount?: number + ): Promise { + const payload: PreFilterNonFindingPromptPayload = { discussionBody, discussionNoteCount }; + try { + const json = await this.llmClient.completeJson( + this.buildPreFilterNonFindingPrompt(payload), + this.preFilterNonFindingSystem(), + { + type: 'object', + properties: { isProbablyNonFinding: { type: 'boolean' }, reason: { type: 'string' } }, + required: ['isProbablyNonFinding', 'reason'], + } + ); + const body = this.parseSimpleJson(json); + if (!body || typeof body.isProbablyNonFinding !== 'boolean') { + return { kind: 'unreliable', reason: 'LLM 返回了不可解析的非 finding 过滤结果' }; + } + return { + kind: 'reliable', + isProbablyNonFinding: body.isProbablyNonFinding, + reason: typeof body.reason === 'string' ? body.reason : '', + }; + } catch (error) { + return { kind: 'unreliable', reason: this.wrapError(error) }; + } + } + + // ---------- 提示构造 ---------- + + private buildPreFilterScopePrompt(payload: PreFilterScopePromptPayload): string { + const loc = payload.findingFile + ? `文件:${payload.findingFile}${payload.findingLine ? `, 行:${payload.findingLine}` : ''}` + : '(文件定位信息未提供)'; + return [ + '你正在帮助维护者判断一个审查问题的大致范围(前置初筛)。', + '目标是辅助判断该问题更可能属于:trivial / local / cross-file / needs-clarification。', + '', + `位置信息:${loc}`, + '', + '问题描述:', + payload.findingDescription, + '', + '范围定义:', + '- trivial:单行、小范围、注释/TODO/命名/常量补全之类,几乎不影响其他代码。', + '- local:集中在单个文件或一个小范围内,修复边界清楚。', + '- cross-file:涉及类型/接口/签名变更或明显影响多个调用点,可能需要多文件一起动。', + '- needs-clarification:关键信息(文件/行/意图)不足以给出范围结论。', + '', + '规则:', + '- 如果位置信息缺失或问题描述过短/模糊,倾向于 needs-clarification。', + '- 如果明显影响接口、签名、导出或多个调用点,倾向于 cross-file。', + '- 如果只能看到单行/字段/注释/常量层面的修改,且无控制流或类型扩散迹象,倾向于 trivial。', + '- 其余则判为 local。', + '', + '请返回 JSON,包含 scope(one of trivial/local/cross-file/needs-clarification)、reason(字符串)。', + ].join('\\\\n'); + } + + private buildPreFilterNonFindingPrompt(payload: PreFilterNonFindingPromptPayload): string { + const noteCount = + payload.discussionNoteCount != null + ? `(讨论 note 数量:${payload.discussionNoteCount})` + : ''; + return [ + '你正在帮助维护者判断:一条 MR discussion 是否很可能不是待逐条修复的代码问题。', + '', + '讨论正文:', + payload.discussionBody, + noteCount, + '', + '判断规则:', + '- 如果正文只询问/提示而不给出具体文件行号的问题,就倾向于 isProbablyNonFinding=true。', + '- 如果正文指向具体文件和行号,且表达的是可操作的代码问题,就倾向于 isProbablyNonFinding=false。', + '- 如果信息不足以判断,保守返回 isProbablyNonFinding=false。', + '', + '请返回 JSON,包含 isProbablyNonFinding(boolean)、reason(字符串)。', + ].join('\\\\n'); + } + + private preFilterScopeSystem(): string { + return '你是一个保守的 scope 初筛辅助。只在理由充分时才给出 non-trivial/non-cross-file 的范围结论;信息不足时不要编造范围。'; + } + + private preFilterNonFindingSystem(): string { + return '你是一个保守的非 finding 过滤辅助。只有在有明确理由时才标记 isProbablyNonFinding=true;怀疑是具体代码问题时,就返回 false。'; + } + + private buildReidentifyPrompt(payload: ReidentifyPromptPayload): string { + const ctx = payload.fileContextHint + ? `\\n\\n当前文件上下文提示:\\n${payload.fileContextHint}` + : ''; + return [ + '你正在帮助维护者判断:同一个代码审查问题是否可能已经在之前的轮次中被处理过。', + '请根据当前发现描述和之前决策摘要,判断二者是否可能是同一个语义问题。', + '', + '当前发现描述:', + payload.currentDescription, + '', + '之前决策摘要:', + payload.previousDecisionSummary, + ctx, + '', + '规则:', + '- 如果之前的决策是 ignore/ignore(alreadyFixed),且当前描述表达的是同样的意图/位置/问题,请倾向认为是同一语义问题。', + '- 如果当前描述明显涉及不同问题、不同意图、不同位置,则不应该认为是同一语义问题。', + '- 仅凭行号相同不足以断定同一语义问题;行号不同也不应直接否定同一语义问题。', + '- 如果信息不足以判断,请返回 likelySame=false 并且 confidence=low。', + '', + '请返回 JSON,包含 likelySame(boolean)、confidence(one of high/medium/low)、reason(字符串)。', + ].join('\\\\n'); + } + + private buildStuckPrompt(payload: StuckPromptPayload): string { + const attempted = payload.attemptedDirectionsSummary + ? `\\n\\n已尝试的方向总结:\\n${payload.attemptedDirectionsSummary}` + : ''; + return [ + '你正在帮助维护者判断:当前修复循环是否应该继续当前方向、换范围、扩大范围,或收拢尝试。', + '', + '问题描述:', + payload.findingDescription, + '', + '最近进度总结:', + payload.recentProgressSummary, + attempted, + '', + '建议动作之一:', + '- continue:当前方向仍有意义,继续。', + '- refocus:当前方向不集中,建议缩小/重新聚焦到更明确的目标。', + '- broaden:当前范围太窄,建议扩大到更多相关文件/行。', + '- stop:当前信息下不再继续有意义,建议收拢或换方式。', + '', + '请返回 JSON,包含 suggestion(one of continue/refocus/broaden/stop)、suggestStop(boolean)、reason(字符串)。', + ].join('\\\\n'); + } + + private buildAlreadyFixedPrompt(payload: AlreadyFixedPromptPayload): string { + const ctx = payload.currentCodeContextHint + ? `\\n\\n当前代码上下文提示:\\n${payload.currentCodeContextHint}` + : ''; + return [ + '你正在帮助维护者判断:某个审查问题是否已经在当前代码中不再存在。', + '', + '问题描述:', + payload.findingDescription, + ctx, + '', + '请回答:该问题是否可能已经在当前代码中不再存在。', + '只返回 JSON,包含:', + '- likelyAlreadyFixed(boolean)', + '- reason(字符串)', + '- evidence(可选字符串,仅在你确信该片段能作为最小证据时提供)', + '', + '注意:如果信息不足,不要武断返回 already-fixed。', + ].join('\\\\n'); + } + + // ---------- system / helper ---------- + + private reidentifySystem(): string { + return '你是一个保守的语义重识别辅助。目标是帮助判断同一语义问题是否已被处理,而不是直接代替人类决策。只有在理由充分时才标记 likelySame=true,并把置信度控制在 reasonable 范围。'; + } + + private stuckSystem(): string { + return '你是一个保守的卡点校正辅助。不要鼓励无限继续;如果信息不足或方向不明确,倾向于 suggestStop=true。'; + } + + private alreadyFixedSystem(): string { + return '你是一个保守的 already-fixed 辅助。只在有理由时标记 likelyAlreadyFixed=true,且不要超过你能从输入中看到的范围。'; + } + + private parseSimpleJson(text: string): Record | null { + try { + const parsed = JSON.parse(text) as Record; + if (parsed && typeof parsed === 'object') { + return parsed; + } + return null; + } catch { + return null; + } + } + + private normalizeConfidence(value: unknown): 'high' | 'medium' | 'low' { + if (value === 'high' || value === 'medium' || value === 'low') { + return value as 'high' | 'medium' | 'low'; + } + return 'low'; + } + + private wrapError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return `LLM 判别辅助失败: ${message}`; + } +} diff --git a/src/advance/classic/fix/maintainer-local-judge-stub.ts b/src/advance/classic/fix/maintainer-local-judge-stub.ts new file mode 100644 index 0000000..9bcbe57 --- /dev/null +++ b/src/advance/classic/fix/maintainer-local-judge-stub.ts @@ -0,0 +1,77 @@ +/** + * Maintainer 判别辅助的保守桩实现 + * + * 当前行为: + * - 所有辅助请求均回退为“不可靠”,不改变现有 Maintainer 逻辑 + * - 后续可替换为本地轻量模型实现,且无需修改调用方 + */ + +import type { + MaintainerLocalJudge, + LocalJudgeVerdict, + SemanticReidentificationResult, + StuckCorrectionResult, + AlreadyFixedAssistanceResult, + PreFilterScopeVerdict, + PreFilterNonFindingVerdict, +} from './maintainer-local-judge.js'; + +export class ConservativeLocalJudgeStub implements MaintainerLocalJudge { + /** 当前桩始终可用(避免调用方因“不可用”而改变流程),但判定均不可靠 */ + isAvailable(): boolean { + return true; + } + + reassessSemanticIdentity( + _currentFindingDescription: string, + _previousDecisionSummary: string, + _fileContextHint?: string + ): Promise { + return Promise.resolve({ + kind: 'unreliable', + reason: '本地判别辅助尚未启用,语义重识别由现有机制处理', + }); + } + + adviseOnStuckProgress( + _findingDescription: string, + _recentProgressSummary: string, + _attemptedDirectionsSummary?: string + ): Promise { + return Promise.resolve({ + kind: 'unreliable', + reason: '本地判别辅助尚未启用,卡点校正由现有熔断逻辑处理', + }); + } + + assistAlreadyFixedCheck( + _findingDescription: string, + _currentCodeContextHint?: string + ): Promise { + return Promise.resolve({ + kind: 'unreliable', + reason: '本地判别辅助尚未启用,already-fixed 判定由现有机制处理', + }); + } + + preFilterScope( + _findingDescription: string, + _findingFile?: string, + _findingLine?: number + ): Promise { + return Promise.resolve({ + kind: 'unreliable', + reason: '本地判别辅助尚未启用,scope 初筛由现有机制处理', + }); + } + + preFilterNonFindingDiscussion( + _discussionBody: string, + _discussionNoteCount?: number + ): Promise { + return Promise.resolve({ + kind: 'unreliable', + reason: '本地判别辅助尚未启用,非 finding 过滤由现有机制处理', + }); + } +} diff --git a/src/advance/classic/fix/maintainer-local-judge.ts b/src/advance/classic/fix/maintainer-local-judge.ts new file mode 100644 index 0000000..9910722 --- /dev/null +++ b/src/advance/classic/fix/maintainer-local-judge.ts @@ -0,0 +1,133 @@ +/** + * Maintainer 判别辅助抽象 + * + * 目标(见本分支方案): + * - 在少数高杠杆语义判断点引入本地轻量判别辅助 + * - 不代替 Maintainer 大脑/Actor 的既有决策,仅作为辅助信号 + * - 不可信/不可用时必须无缝回退到现有机制 + */ + +export type LocalJudgeVerdict = + | { kind: 'reliable'; value: boolean; reason: string } + | { kind: 'unreliable'; reason: string }; + +export type PreFilterScopeVerdict = + | { + kind: 'reliable'; + scope: 'trivial' | 'local' | 'cross-file' | 'needs-clarification'; + reason: string; + } + | { kind: 'unreliable'; reason: string }; + +export type PreFilterNonFindingVerdict = + | { kind: 'reliable'; isProbablyNonFinding: boolean; reason: string } + | { kind: 'unreliable'; reason: string }; + +/** + * 语义重识别辅助的结果 + */ +export interface SemanticReidentificationResult { + /** 当前 finding 与之前记录的决策是否可能是同一语义问题 */ + likelySame: boolean; + /** 判定依据 */ + reason: string; + /** 置信程度,用于决定是否覆盖/复用历史决策 */ + confidence: 'high' | 'medium' | 'low'; +} + +/** + * 工具循环卡点校正辅助的结果 + */ +export interface StuckCorrectionResult { + /** 建议动作 */ + suggestion: 'continue' | 'refocus' | 'broaden' | 'stop'; + /** 建议理由 */ + reason: string; + /** 是否建议停止当前方向的探索 */ + suggestStop: boolean; +} + +/** + * 已修复辅助的结果(可选的增强点) + */ +export interface AlreadyFixedAssistanceResult { + /** 问题是否可能已经在当前代码中不存在 */ + likelyAlreadyFixed: boolean; + /** 判定依据 */ + reason: string; + /** 可选的最小证据片段(若提供,须经调用方 grounded 校验) */ + evidence?: string; +} + +/** + * 本地判别辅助的抽象接口 + * + * 实现方可根据本部署能力选择本地 LLM 服务、MCP 桥或后续多模型配置; + * 本接口不绑死具体后端。 + */ +export interface MaintainerLocalJudge { + /** + * 服务当前是否可用 + */ + isAvailable(): boolean; + + /** + * 语义重识别辅助 + * + * 用在 MR HEAD 变化、文件结构变化后,判断当前 finding 是否与历史决策 + * 属于同一语义问题,从而避免已处理问题被重复回复/重复检查。 + */ + reassessSemanticIdentity( + currentFindingDescription: string, + previousDecisionSummary: string, + fileContextHint?: string + ): Promise; + + /** + * 工具循环卡点校正辅助 + * + * 在 FixToolLoop 探测到低效徘徊或长时间无实质进展时,可选调用, + * 用于提前转向或收拢探索方向。 + */ + adviseOnStuckProgress( + findingDescription: string, + recentProgressSummary: string, + attemptedDirectionsSummary?: string + ): Promise; + + /** + * 已修复辅助(可选增强点) + * + * 在 already-fixed 判定阶段提供辅助信号。 + * 输出的证据必须经调用方 grounded 校验,不得直接覆盖现有结论。 + */ + assistAlreadyFixedCheck( + findingDescription: string, + currentCodeContextHint?: string + ): Promise; + + /** + * Scope 初筛辅助(中优先级位置) + * + * 在启发式规则尚未给出明确范围结论时,辅助判断一个 finding + * 更可能属于 trivial / local / cross-file / needs-clarification。 + * 不可靠时返回不可靠,不改变现有范围判定。 + */ + preFilterScope( + findingDescription: string, + findingFile?: string, + findingLine?: number + ): Promise; + + /** + * 非 finding 讨论的语义过滤辅助(中优先级位置) + * + * 在 discussion 无法解析出具体文件/行号时,辅助判断该讨论是否 + * 很可能不是待逐条修复的代码问题(例如纯统计/汇总/提问_hint/误报指示等)。 + * 不可靠时返回不可靠,调用方继续走原有非 finding 处理路径。 + */ + preFilterNonFindingDiscussion( + discussionBody: string, + discussionNoteCount?: number + ): Promise; +} diff --git a/src/advance/classic/fix/patch-applier.ts b/src/advance/classic/fix/patch-applier.ts index ffc522b..9724b7a 100755 --- a/src/advance/classic/fix/patch-applier.ts +++ b/src/advance/classic/fix/patch-applier.ts @@ -157,8 +157,8 @@ export function applyPatch(originalContent: string, filePatch: FilePatch): Apply }; } - const oldLines = hunk.lines.filter((l) => l.type !== 'add').map((l) => l.content); - const replacement = hunk.lines.filter((l) => l.type !== 'remove').map((l) => l.content); + const oldLines = hunk.lines.filter(l => l.type !== 'add').map(l => l.content); + const replacement = hunk.lines.filter(l => l.type !== 'remove').map(l => l.content); result.splice(pos, oldLines.length, ...replacement); offset += replacement.length - oldLines.length; } @@ -181,7 +181,7 @@ function findHunkPosition( hintIndex: number, windowSize = 20 ): number { - const oldLines = hunk.lines.filter((l) => l.type !== 'add').map((l) => l.content); + const oldLines = hunk.lines.filter(l => l.type !== 'add').map(l => l.content); if (oldLines.length === 0) { // 没有旧内容需要匹配时,直接返回 hint return Math.max(0, Math.min(hintIndex, fileLines.length)); diff --git a/src/advance/classic/fix/tools/setup-command-safety-filter.ts b/src/advance/classic/fix/tools/setup-command-safety-filter.ts index c34d212..925f025 100755 --- a/src/advance/classic/fix/tools/setup-command-safety-filter.ts +++ b/src/advance/classic/fix/tools/setup-command-safety-filter.ts @@ -5,15 +5,25 @@ */ export class SetupCommandSafetyFilter { private readonly dangerousPatterns = [ - /;/, /&&/, /\|/, />/, //, + / MAX_READ_OUTPUT_FILE_SIZE && tailLines === undefined) { - throw new Error( - `输出文件过大(${stats.size} bytes),请使用 tailLines 参数读取尾部关键内容` - ); + throw new Error(`输出文件过大(${stats.size} bytes),请使用 tailLines 参数读取尾部关键内容`); } if (tailLines === undefined || tailLines <= 0) { @@ -191,7 +196,11 @@ function formatValidateResult( lint: result.lint, typecheck: result.typecheck, lintSummary: lintLong - ? truncateText(result.lintReason ?? '', MAX_OUTPUT_PREVIEW_LENGTH, result.lint ? 'head' : 'tail') + ? truncateText( + result.lintReason ?? '', + MAX_OUTPUT_PREVIEW_LENGTH, + result.lint ? 'head' : 'tail' + ) : result.lintReason, typecheckSummary: typecheckLong ? truncateText( @@ -244,7 +253,11 @@ function formatToolResult( }; } } - if (toolName === 'read_file' && typeof result === 'string' && result.length > MAX_FILE_CONTENT_LENGTH) { + if ( + toolName === 'read_file' && + typeof result === 'string' && + result.length > MAX_FILE_CONTENT_LENGTH + ) { const outputFile = writeToolOutputFile(worktreePath, toolCallId, result); return { content: truncateText(result, MAX_FILE_CONTENT_LENGTH, 'head'), @@ -266,7 +279,9 @@ export class ToolExecutor { this.worktreeManager = options.worktreeManager; this.memoryClient = options.memoryClient; this.recallPlanner = options.recallPlanner; - this.allowedScripts = new Set(options.allowedScripts ?? ['lint', 'typecheck', 'build', 'test', 'compile:packages']); + this.allowedScripts = new Set( + options.allowedScripts ?? ['lint', 'typecheck', 'build', 'test', 'compile:packages'] + ); } /** @@ -324,7 +339,9 @@ export class ToolExecutor { } } - private async readOutputFile(input: Record): Promise<{ content: string; tailLines?: number }> { + private async readOutputFile( + input: Record + ): Promise<{ content: string; tailLines?: number }> { const outputFile = this.requireString(input, 'outputFile'); const tailLines = this.optionalNumber(input, 'tailLines'); const worktreePath = this.worktreeManager.getWorktreePath(); @@ -395,7 +412,9 @@ export class ToolExecutor { return this.worktreeManager.searchWorkspace(keyword); } - private async writeFile(input: Record): Promise<{ written: true; unchanged: boolean; appended: boolean }> { + private async writeFile( + input: Record + ): Promise<{ written: true; unchanged: boolean; appended: boolean }> { const relPath = this.requireString(input, 'relPath'); // content 允许为空字符串:清空文件是合法的修复操作 const content = this.requireContent(input); @@ -448,7 +467,9 @@ export class ToolExecutor { return { applied: true }; } - private async runSetupCommand(input: Record): Promise<{ success: boolean; reason?: string }> { + private async runSetupCommand( + input: Record + ): Promise<{ success: boolean; reason?: string }> { const command = this.requireString(input, 'command'); const cwd = this.optionalString(input, 'cwd'); @@ -460,13 +481,19 @@ export class ToolExecutor { return this.worktreeManager.runSetupCommand(command, cwd); } - private async runScript(input: Record): Promise<{ success: boolean; reason?: string }> { + private async runScript( + input: Record + ): Promise<{ success: boolean; reason?: string }> { const script = this.requireString(input, 'script'); if (!this.allowedScripts.has(script)) { - throw new Error(`脚本 ${script} 不在白名单内,只允许: ${Array.from(this.allowedScripts).join(', ')}`); + throw new Error( + `脚本 ${script} 不在白名单内,只允许: ${Array.from(this.allowedScripts).join(', ')}` + ); } const args = this.optionalStringArray(input, 'args'); - return args ? this.worktreeManager.runScript(script, args) : this.worktreeManager.runScript(script); + return args + ? this.worktreeManager.runScript(script, args) + : this.worktreeManager.runScript(script); } private async validate(): Promise<{ diff --git a/src/advance/classic/fix/validation-strategy.ts b/src/advance/classic/fix/validation-strategy.ts index c60a772..b796a05 100755 --- a/src/advance/classic/fix/validation-strategy.ts +++ b/src/advance/classic/fix/validation-strategy.ts @@ -48,9 +48,7 @@ export class WorkspaceValidationStrategy implements ValidationStrategy { const passed = raw.lint === true && raw.typecheck === true; return { passed, - reason: passed - ? 'lint 和 typecheck 均通过' - : `lint=${raw.lint}, typecheck=${raw.typecheck}`, + reason: passed ? 'lint 和 typecheck 均通过' : `lint=${raw.lint}, typecheck=${raw.typecheck}`, details: raw, }; } diff --git a/src/advance/classic/memory/everos-api.ts b/src/advance/classic/memory/everos-api.ts index 6784097..c8dbe2a 100755 --- a/src/advance/classic/memory/everos-api.ts +++ b/src/advance/classic/memory/everos-api.ts @@ -1,4 +1,4 @@ -import { logger } from '../../../core/logger.js'; +import { logger } from '../../core/logger.js'; import { sanitizeEverOSId } from './types.js'; /** @@ -74,7 +74,7 @@ function isRetryableStatus(status: number): boolean { } function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise(resolve => setTimeout(resolve, ms)); } /** @@ -136,7 +136,7 @@ export async function everosMemoryAddMessages( app_id: sanitizeEverOSId(params.appId), project_id: sanitizeEverOSId(params.projectId), session_id: sanitizeEverOSId(params.sessionId), - messages: messages.map((m) => ({ + messages: messages.map(m => ({ sender_id: sanitizeEverOSId(m.senderId), role: m.role, timestamp: m.timestamp ?? Date.now(), @@ -295,11 +295,15 @@ export async function everosMemorySearch( body.agent_id = sanitizeEverOSId(params.owner.agentId); } - const res = await fetchWithRetry(`${everosUrl}/api/v1/memory/search`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }, { timeoutMs: 60000 }); + const res = await fetchWithRetry( + `${everosUrl}/api/v1/memory/search`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + { timeoutMs: 60000 } + ); if (!res.ok) { throw new Error(`EverOS memory/search 失败: ${res.status} ${await res.text()}`); } @@ -328,11 +332,15 @@ export async function everosMemorySearchProject( enable_llm_rerank: true, }; - const res = await fetchWithRetry(`${everosUrl}/api/v1/memory/search`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }, { timeoutMs: 60000 }); + const res = await fetchWithRetry( + `${everosUrl}/api/v1/memory/search`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + { timeoutMs: 60000 } + ); if (!res.ok) { throw new Error(`EverOS memory/search(project) 失败: ${res.status} ${await res.text()}`); } diff --git a/src/advance/classic/memory/everos-mcp-server.ts b/src/advance/classic/memory/everos-mcp-server.ts index e983ad9..48c4c7d 100755 --- a/src/advance/classic/memory/everos-mcp-server.ts +++ b/src/advance/classic/memory/everos-mcp-server.ts @@ -8,7 +8,7 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import http, { type Server as HttpServer } from 'node:http'; import { URL } from 'node:url'; -import { logger } from '../../../core/logger.js'; +import { logger } from '../../core/logger.js'; import { SecretSanitizer } from './secret-sanitizer.js'; import type { MemoryContext, @@ -924,14 +924,26 @@ export class EverOSMcpServer { query: string; }): Promise { const ctx = args.context; - const result = await everosMemorySearch(this.everosUrl, { - appId: ctx.appId, - projectId: ctx.projectId, - owner: { kind: 'agent', agentId: 'archiver' }, - query: args.query, - topK: 5, - }); - return result.items; + // 项目知识的两个 owner 口径:archiver 蒸馏产物 + 智库正本投影(M6) + const owners = ['archiver', 'knowledge-projection']; + const merged: EverOSSearchItem[] = []; + const seen = new Set(); + for (const agentId of owners) { + const result = await everosMemorySearch(this.everosUrl, { + appId: ctx.appId, + projectId: ctx.projectId, + owner: { kind: 'agent', agentId }, + query: args.query, + topK: 5, + }); + for (const item of result.items) { + if (!seen.has(item.id)) { + seen.add(item.id); + merged.push(item); + } + } + } + return merged.slice(0, 5); } private async handleMemoryDelete( diff --git a/src/advance/classic/memory/everos-service.ts b/src/advance/classic/memory/everos-service.ts index 6b81c6d..c085547 100755 --- a/src/advance/classic/memory/everos-service.ts +++ b/src/advance/classic/memory/everos-service.ts @@ -1,327 +1,342 @@ -import { spawn, type ChildProcess } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { mkdir } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; -import { logger } from '../../../core/logger.js'; -import { getAppStorageDir } from '../../../core/platform.js'; -import { ensureFcntlShim, buildEverOSProcessEnv } from './everos-windows-shim.js'; - -export interface EverOSServiceOptions { - /** EverOS submodule 根目录 */ - submodulePath: string; - /** 数据目录;默认 ~/.codekeeper/everos-data */ - dataDir?: string; - /** 指定端口;0 表示由 EverOS 自己选择 */ - port?: number; - /** 传递给 EverOS 子进程的环境变量 */ - env?: NodeJS.ProcessEnv; -} - -/** - * 管理 EverOS 本地服务的生命周期 - */ -/** - * 追加文本到滚动缓冲区,超过上限时保留最近部分,避免子进程海量输出撑爆主进程堆内存。 - */ -function appendCapped(buffer: string, text: string, maxBytes: number): string { - if (buffer.length + text.length <= maxBytes) { - return buffer + text; - } - return (buffer + text).slice(-maxBytes); -} - -export class EverOSService { - private process: ChildProcess | null = null; - private readonly submodulePath: string; - private readonly dataDir: string; - private readonly port: number; - private readonly env: NodeJS.ProcessEnv; - private readonly shimDir: string; - private everosUrl: string | null = null; - - constructor(options: EverOSServiceOptions) { - this.submodulePath = resolve(options.submodulePath); - this.dataDir = options.dataDir ?? join(getAppStorageDir(), 'everos-data'); - this.port = options.port ?? 0; - this.env = options.env ?? process.env; - this.shimDir = join(getAppStorageDir(), 'everos-windows-shim'); - } - - /** - * 启动 EverOS 服务 - * @returns EverOS HTTP URL,例如 http://127.0.0.1:8000 - */ - async start(): Promise { - if (this.process) { - if (!this.everosUrl) { - throw new Error('EverOS URL 尚未就绪'); - } - return this.everosUrl; - } - - await this.ensureVenv(); - await this.ensureShim(); - await mkdir(this.dataDir, { recursive: true }); - await this.checkCompatibility(); - await this.ensureInitialized(); - - const everosCli = this.everosCliPath(); - const args = ['server', 'start', '--root', this.dataDir, '--port', String(this.port)]; - - logger.info({ everosCli, args }, '启动 EverOS 服务'); - - return new Promise((resolve, reject) => { - const child = spawn(everosCli, args, { - cwd: this.submodulePath, - stdio: ['ignore', 'pipe', 'pipe'], - env: this.getProcessEnv(), - }); - this.process = child; - - const maxOutputChars = 100 * 1024; - const maxLineBufferChars = 10 * 1024; - let stdout = ''; - let stderr = ''; - let stdoutLineBuffer = ''; - let stderrLineBuffer = ''; - let urlScanBuffer = ''; - let stdoutCapped = false; - let stderrCapped = false; - - child.stdout?.on('data', (chunk) => { - const text = chunk.toString(); - stdout = appendCapped(stdout, text, maxOutputChars); - if (!stdoutCapped && stdout.length >= maxOutputChars) { - stdoutCapped = true; - logger.warn('[EverOS] stdout 输出超过 100KB,已滚动截断'); - } - stdoutLineBuffer = this.flushLogLines(stdoutLineBuffer + text, (line) => { - if (this.shouldLogStdoutLine(line)) { - logger.info({ everos: line }, '[EverOS]'); - } - }); - if (stdoutLineBuffer.length > maxLineBufferChars) { - logger.warn(`[EverOS] stdout 行缓冲超过 ${maxLineBufferChars} 字符,强制刷新并截断`); - this.flushLogLines(stdoutLineBuffer + '\n', (line) => { - if (this.shouldLogStdoutLine(line)) { - logger.info({ everos: line }, '[EverOS]'); - } - }); - stdoutLineBuffer = ''; - } - urlScanBuffer = appendCapped(urlScanBuffer, text, 1024); - this.tryParseUrl(urlScanBuffer, resolve); - }); - - child.stderr?.on('data', (chunk) => { - const text = chunk.toString(); - stderr = appendCapped(stderr, text, maxOutputChars); - if (!stderrCapped && stderr.length >= maxOutputChars) { - stderrCapped = true; - logger.warn('[EverOS] stderr 输出超过 100KB,已滚动截断'); - } - stderrLineBuffer = this.flushLogLines(stderrLineBuffer + text, (line) => { - if (this.shouldLogStderrLine(line)) { - logger.warn({ everos: line }, '[EverOS]'); - } - }); - if (stderrLineBuffer.length > maxLineBufferChars) { - logger.warn(`[EverOS] stderr 行缓冲超过 ${maxLineBufferChars} 字符,强制刷新并截断`); - this.flushLogLines(stderrLineBuffer + '\n', (line) => { - if (this.shouldLogStderrLine(line)) { - logger.warn({ everos: line }, '[EverOS]'); - } - }); - stderrLineBuffer = ''; - } - }); - - const flushRemainingLogs = () => { - this.flushLogLines(stdoutLineBuffer + '\n', (line) => { - if (this.shouldLogStdoutLine(line)) { - logger.info({ everos: line }, '[EverOS]'); - } - }); - stdoutLineBuffer = ''; - this.flushLogLines(stderrLineBuffer + '\n', (line) => { - if (this.shouldLogStderrLine(line)) { - logger.warn({ everos: line }, '[EverOS]'); - } - }); - stderrLineBuffer = ''; - }; - - child.on('error', (err) => { - flushRemainingLogs(); - this.process = null; - reject(new Error(`启动 EverOS 失败: ${err.message}`)); - }); - - child.on('exit', (code) => { - flushRemainingLogs(); - this.process = null; - if (!this.everosUrl) { - reject(new Error(`EverOS 进程退出 code=${code}, stdout=${stdout}, stderr=${stderr}`)); - } - }); - - setTimeout(() => { - if (!this.everosUrl) { - this.stop(); - reject(new Error('EverOS 启动超时,未检测到服务 URL')); - } - }, 60000); - }); - } - - /** - * 停止 EverOS 服务 - */ - stop(): void { - if (!this.process) return; - this.process.kill('SIGTERM'); - const proc = this.process; - setTimeout(() => { - if (!proc.killed) { - proc.kill('SIGKILL'); - } - }, 5000); - this.process = null; - this.everosUrl = null; - } - - private async ensureVenv(): Promise { - const cli = this.everosCliPath(); - if (existsSync(this.venvPath()) && existsSync(cli)) return; - - const python = await this.findPython(); - const venvPath = this.venvPath(); - if (!existsSync(venvPath)) { - logger.info({ python, venvPath }, '创建 EverOS 虚拟环境'); - await this.runCommand(python, ['-m', 'venv', venvPath]); - } - - const pip = this.pipPath(); - logger.info({ pip }, '安装 EverOS 依赖'); - await this.runCommand(pip, ['install', '-e', this.submodulePath]); - } - - private async ensureInitialized(): Promise { - const configPath = join(this.dataDir, 'everos.toml'); - if (existsSync(configPath)) return; - const everosCli = this.everosCliPath(); - logger.info({ dataDir: this.dataDir }, '初始化 Ever OS 配置'); - await this.runCommand(everosCli, ['init', '--root', this.dataDir]); - } - - /** - * Windows 下生成 fcntl shim,让原生 Python 也能加载 EverOS。 - */ - private async ensureShim(): Promise { - if (process.platform !== 'win32') return; - await ensureFcntlShim(this.shimDir); - logger.info({ shimDir: this.shimDir }, '已生成 Windows fcntl shim'); - } - - /** - * 检查当前环境是否能运行 EverOS。 - * - * Windows 下通过 fcntl shim 让原生 Python 也能加载该模块。 - */ - private async checkCompatibility(): Promise { - try { - await this.runCommand(this.pythonPath(), ['-c', 'import fcntl']); - } catch { - throw new Error( - 'EverOS 启动环境检查失败:无法加载 fcntl 兼容层,请确认虚拟环境已正确安装 portalocker' - ); - } - } - - private async findPython(): Promise { - const candidates = process.platform === 'win32' ? ['python.exe', 'python3.exe'] : ['python3', 'python']; - for (const cmd of candidates) { - try { - await this.runCommand(cmd, ['--version']); - return cmd; - } catch { - // 继续尝试下一个 - } - } - throw new Error('未找到 Python 解释器,请安装 Python 3.12+'); - } - - private async runCommand(command: string, args: string[]): Promise { - await new Promise((resolve, reject) => { - const child = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'], env: this.getProcessEnv() }); - let stderr = ''; - child.stderr?.on('data', (chunk) => { - stderr += chunk.toString(); - }); - child.on('error', reject); - child.on('exit', (code) => { - if (code === 0) resolve(); - else reject(new Error(`命令失败: ${command} ${args.join(' ')},code=${code},stderr=${stderr}`)); - }); - }); - } - - private getProcessEnv(): NodeJS.ProcessEnv { - return buildEverOSProcessEnv(process.platform, this.env, this.shimDir); - } - - private venvPath(): string { - return join(getAppStorageDir(), 'everos-venv'); - } - - private pythonPath(): string { - return join(this.venvPath(), process.platform === 'win32' ? 'Scripts\\python.exe' : 'bin/python'); - } - - private pipPath(): string { - return join(this.venvPath(), process.platform === 'win32' ? 'Scripts\\pip.exe' : 'bin/pip'); - } - - private everosCliPath(): string { - return join(this.venvPath(), process.platform === 'win32' ? 'Scripts\\everos.exe' : 'bin/everos'); - } - - private shouldLogStdoutLine(line: string): boolean { - // 过滤掉 Uvicorn 常规成功访问日志,避免记忆查询等高频率请求刷屏 - return !this.isRoutineAccessLog(line); - } - - /** - * 将缓冲区文本按行切分,输出完整行并返回未结束的部分 - */ - private flushLogLines(buffer: string, emit: (line: string) => void): string { - const lines = buffer.split('\n'); - const remaining = lines.pop() ?? ''; - for (const raw of lines) { - const trimmed = raw.replace(/\r$/, '').trim(); - if (trimmed) emit(trimmed); - } - return remaining; - } - - private shouldLogStderrLine(line: string): boolean { - // stderr 一般只记录错误与警告,但同样过滤掉可能的访问日志 - return !this.isRoutineAccessLog(line); - } - - private isRoutineAccessLog(line: string): boolean { - // 匹配形如:127.0.0.1:62800 - "POST /api/v1/memory/get HTTP/1.1" 200 - // 实际 stdout 行可能带前置时间戳和 [info ] 前缀,因此不锚定行首 - return /\d{1,3}(?:\.\d{1,3}){3}:\d+\s+-\s+"[A-Z]+\s+\S+\s+HTTP\/\d(?:\.\d)?"\s+(?:2\d{2}|304)\b/.test(line); - } - - private tryParseUrl(stdout: string, resolve: (url: string) => void): void { - if (this.everosUrl) return; - // Uvicorn 启动时会打印服务器 URL,例如: Uvicorn running on http://127.0.0.1:8000 - const match = stdout.match(/(https?:\/\/127\.0\.0\.1:\d+)/); - if (match) { - this.everosUrl = match[1]; - resolve(this.everosUrl); - } - } -} +import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { logger } from '../../core/logger.js'; +import { getAppStorageDir } from '../../core/platform.js'; +import { ensureFcntlShim, buildEverOSProcessEnv } from './everos-windows-shim.js'; + +export interface EverOSServiceOptions { + /** EverOS submodule 根目录 */ + submodulePath: string; + /** 数据目录;默认 ~/.codekeeper/everos-data */ + dataDir?: string; + /** 指定端口;0 表示由 EverOS 自己选择 */ + port?: number; + /** 传递给 EverOS 子进程的环境变量 */ + env?: NodeJS.ProcessEnv; +} + +/** + * 管理 EverOS 本地服务的生命周期 + */ +/** + * 追加文本到滚动缓冲区,超过上限时保留最近部分,避免子进程海量输出撑爆主进程堆内存。 + */ +function appendCapped(buffer: string, text: string, maxBytes: number): string { + if (buffer.length + text.length <= maxBytes) { + return buffer + text; + } + return (buffer + text).slice(-maxBytes); +} + +export class EverOSService { + private process: ChildProcess | null = null; + private readonly submodulePath: string; + private readonly dataDir: string; + private readonly port: number; + private readonly env: NodeJS.ProcessEnv; + private readonly shimDir: string; + private everosUrl: string | null = null; + + constructor(options: EverOSServiceOptions) { + this.submodulePath = resolve(options.submodulePath); + this.dataDir = options.dataDir ?? join(getAppStorageDir(), 'everos-data'); + this.port = options.port ?? 0; + this.env = options.env ?? process.env; + this.shimDir = join(getAppStorageDir(), 'everos-windows-shim'); + } + + /** + * 启动 EverOS 服务 + * @returns EverOS HTTP URL,例如 http://127.0.0.1:8000 + */ + async start(): Promise { + if (this.process) { + if (!this.everosUrl) { + throw new Error('EverOS URL 尚未就绪'); + } + return this.everosUrl; + } + + await this.ensureVenv(); + await this.ensureShim(); + await mkdir(this.dataDir, { recursive: true }); + await this.checkCompatibility(); + await this.ensureInitialized(); + + const everosCli = this.everosCliPath(); + const args = ['server', 'start', '--root', this.dataDir, '--port', String(this.port)]; + + logger.info({ everosCli, args }, '启动 EverOS 服务'); + + return new Promise((resolve, reject) => { + const child = spawn(everosCli, args, { + cwd: this.submodulePath, + stdio: ['ignore', 'pipe', 'pipe'], + env: this.getProcessEnv(), + }); + this.process = child; + + const maxOutputChars = 100 * 1024; + const maxLineBufferChars = 10 * 1024; + let stdout = ''; + let stderr = ''; + let stdoutLineBuffer = ''; + let stderrLineBuffer = ''; + let urlScanBuffer = ''; + let stdoutCapped = false; + let stderrCapped = false; + + child.stdout?.on('data', chunk => { + const text = chunk.toString(); + stdout = appendCapped(stdout, text, maxOutputChars); + if (!stdoutCapped && stdout.length >= maxOutputChars) { + stdoutCapped = true; + logger.warn('[EverOS] stdout 输出超过 100KB,已滚动截断'); + } + stdoutLineBuffer = this.flushLogLines(stdoutLineBuffer + text, line => { + if (this.shouldLogStdoutLine(line)) { + logger.info({ everos: line }, '[EverOS]'); + } + }); + if (stdoutLineBuffer.length > maxLineBufferChars) { + logger.warn(`[EverOS] stdout 行缓冲超过 ${maxLineBufferChars} 字符,强制刷新并截断`); + this.flushLogLines(stdoutLineBuffer + '\n', line => { + if (this.shouldLogStdoutLine(line)) { + logger.info({ everos: line }, '[EverOS]'); + } + }); + stdoutLineBuffer = ''; + } + urlScanBuffer = appendCapped(urlScanBuffer, text, 1024); + this.tryParseUrl(urlScanBuffer, resolve); + }); + + child.stderr?.on('data', chunk => { + const text = chunk.toString(); + stderr = appendCapped(stderr, text, maxOutputChars); + if (!stderrCapped && stderr.length >= maxOutputChars) { + stderrCapped = true; + logger.warn('[EverOS] stderr 输出超过 100KB,已滚动截断'); + } + stderrLineBuffer = this.flushLogLines(stderrLineBuffer + text, line => { + if (this.shouldLogStderrLine(line)) { + logger.warn({ everos: line }, '[EverOS]'); + } + }); + if (stderrLineBuffer.length > maxLineBufferChars) { + logger.warn(`[EverOS] stderr 行缓冲超过 ${maxLineBufferChars} 字符,强制刷新并截断`); + this.flushLogLines(stderrLineBuffer + '\n', line => { + if (this.shouldLogStderrLine(line)) { + logger.warn({ everos: line }, '[EverOS]'); + } + }); + stderrLineBuffer = ''; + } + }); + + const flushRemainingLogs = () => { + this.flushLogLines(stdoutLineBuffer + '\n', line => { + if (this.shouldLogStdoutLine(line)) { + logger.info({ everos: line }, '[EverOS]'); + } + }); + stdoutLineBuffer = ''; + this.flushLogLines(stderrLineBuffer + '\n', line => { + if (this.shouldLogStderrLine(line)) { + logger.warn({ everos: line }, '[EverOS]'); + } + }); + stderrLineBuffer = ''; + }; + + child.on('error', err => { + flushRemainingLogs(); + this.process = null; + reject(new Error(`启动 EverOS 失败: ${err.message}`)); + }); + + child.on('exit', code => { + flushRemainingLogs(); + this.process = null; + if (!this.everosUrl) { + reject(new Error(`EverOS 进程退出 code=${code}, stdout=${stdout}, stderr=${stderr}`)); + } + }); + + setTimeout(() => { + if (!this.everosUrl) { + this.stop(); + reject(new Error('EverOS 启动超时,未检测到服务 URL')); + } + }, 60000); + }); + } + + /** + * 停止 EverOS 服务 + */ + stop(): void { + if (!this.process) return; + this.process.kill('SIGTERM'); + const proc = this.process; + setTimeout(() => { + if (!proc.killed) { + proc.kill('SIGKILL'); + } + }, 5000); + this.process = null; + this.everosUrl = null; + } + + private async ensureVenv(): Promise { + const cli = this.everosCliPath(); + if (existsSync(this.venvPath()) && existsSync(cli)) return; + + const python = await this.findPython(); + const venvPath = this.venvPath(); + if (!existsSync(venvPath)) { + logger.info({ python, venvPath }, '创建 EverOS 虚拟环境'); + await this.runCommand(python, ['-m', 'venv', venvPath]); + } + + const pip = this.pipPath(); + logger.info({ pip }, '安装 EverOS 依赖'); + await this.runCommand(pip, ['install', '-e', this.submodulePath]); + } + + private async ensureInitialized(): Promise { + const configPath = join(this.dataDir, 'everos.toml'); + if (existsSync(configPath)) return; + const everosCli = this.everosCliPath(); + logger.info({ dataDir: this.dataDir }, '初始化 Ever OS 配置'); + await this.runCommand(everosCli, ['init', '--root', this.dataDir]); + } + + /** + * Windows 下生成 fcntl shim,让原生 Python 也能加载 EverOS。 + */ + private async ensureShim(): Promise { + if (process.platform !== 'win32') return; + await ensureFcntlShim(this.shimDir); + logger.info({ shimDir: this.shimDir }, '已生成 Windows fcntl shim'); + } + + /** + * 检查当前环境是否能运行 EverOS。 + * + * Windows 下通过 fcntl shim 让原生 Python 也能加载该模块。 + */ + private async checkCompatibility(): Promise { + try { + await this.runCommand(this.pythonPath(), ['-c', 'import fcntl']); + } catch { + throw new Error( + 'EverOS 启动环境检查失败:无法加载 fcntl 兼容层,请确认虚拟环境已正确安装 portalocker' + ); + } + } + + private async findPython(): Promise { + const candidates = + process.platform === 'win32' ? ['python.exe', 'python3.exe'] : ['python3', 'python']; + for (const cmd of candidates) { + try { + await this.runCommand(cmd, ['--version']); + return cmd; + } catch { + // 继续尝试下一个 + } + } + throw new Error('未找到 Python 解释器,请安装 Python 3.12+'); + } + + private async runCommand(command: string, args: string[]): Promise { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ['ignore', 'ignore', 'pipe'], + env: this.getProcessEnv(), + }); + let stderr = ''; + child.stderr?.on('data', chunk => { + stderr += chunk.toString(); + }); + child.on('error', reject); + child.on('exit', code => { + if (code === 0) resolve(); + else + reject( + new Error(`命令失败: ${command} ${args.join(' ')},code=${code},stderr=${stderr}`) + ); + }); + }); + } + + private getProcessEnv(): NodeJS.ProcessEnv { + return buildEverOSProcessEnv(process.platform, this.env, this.shimDir); + } + + private venvPath(): string { + return join(getAppStorageDir(), 'everos-venv'); + } + + private pythonPath(): string { + return join( + this.venvPath(), + process.platform === 'win32' ? 'Scripts\\python.exe' : 'bin/python' + ); + } + + private pipPath(): string { + return join(this.venvPath(), process.platform === 'win32' ? 'Scripts\\pip.exe' : 'bin/pip'); + } + + private everosCliPath(): string { + return join( + this.venvPath(), + process.platform === 'win32' ? 'Scripts\\everos.exe' : 'bin/everos' + ); + } + + private shouldLogStdoutLine(line: string): boolean { + // 过滤掉 Uvicorn 常规成功访问日志,避免记忆查询等高频率请求刷屏 + return !this.isRoutineAccessLog(line); + } + + /** + * 将缓冲区文本按行切分,输出完整行并返回未结束的部分 + */ + private flushLogLines(buffer: string, emit: (line: string) => void): string { + const lines = buffer.split('\n'); + const remaining = lines.pop() ?? ''; + for (const raw of lines) { + const trimmed = raw.replace(/\r$/, '').trim(); + if (trimmed) emit(trimmed); + } + return remaining; + } + + private shouldLogStderrLine(line: string): boolean { + // stderr 一般只记录错误与警告,但同样过滤掉可能的访问日志 + return !this.isRoutineAccessLog(line); + } + + private isRoutineAccessLog(line: string): boolean { + // 匹配形如:127.0.0.1:62800 - "POST /api/v1/memory/get HTTP/1.1" 200 + // 实际 stdout 行可能带前置时间戳和 [info ] 前缀,因此不锚定行首 + return /\d{1,3}(?:\.\d{1,3}){3}:\d+\s+-\s+"[A-Z]+\s+\S+\s+HTTP\/\d(?:\.\d)?"\s+(?:2\d{2}|304)\b/.test( + line + ); + } + + private tryParseUrl(stdout: string, resolve: (url: string) => void): void { + if (this.everosUrl) return; + // Uvicorn 启动时会打印服务器 URL,例如: Uvicorn running on http://127.0.0.1:8000 + const match = stdout.match(/(https?:\/\/127\.0\.0\.1:\d+)/); + if (match) { + this.everosUrl = match[1]; + resolve(this.everosUrl); + } + } +} diff --git a/src/advance/classic/memory/graph-builder.ts b/src/advance/classic/memory/graph-builder.ts index 61eff30..9fc4304 100755 --- a/src/advance/classic/memory/graph-builder.ts +++ b/src/advance/classic/memory/graph-builder.ts @@ -61,10 +61,31 @@ export function buildMemoryGraph(input: BuildMemoryGraphInput): MemoryGraph { const projectNodeId = projectNodeMap.get(projectId); if (!projectNodeId) continue; for (const item of result.episodes ?? []) { - processEntry(item, 'episode', projectId, projectNodeId, nodes, edges, userTopics, projectUsers, displayNames, episodeNodeMap); + processEntry( + item, + 'episode', + projectId, + projectNodeId, + nodes, + edges, + userTopics, + projectUsers, + displayNames, + episodeNodeMap + ); } for (const item of result.agent_cases ?? []) { - processEntry(item, 'agent_case', projectId, projectNodeId, nodes, edges, userTopics, projectUsers, displayNames); + processEntry( + item, + 'agent_case', + projectId, + projectNodeId, + nodes, + edges, + userTopics, + projectUsers, + displayNames + ); } } @@ -73,7 +94,17 @@ export function buildMemoryGraph(input: BuildMemoryGraphInput): MemoryGraph { const projectNodeId = projectNodeMap.get(projectId); if (!projectNodeId) continue; for (const item of result.agent_skills ?? []) { - processSkill(item, projectId, projectNodeId, nodes, edges, caseProjects, skillCases, systemSkills, displayNames); + processSkill( + item, + projectId, + projectNodeId, + nodes, + edges, + caseProjects, + skillCases, + systemSkills, + displayNames + ); } for (const item of result.profiles ?? []) { processProfile(item, projectId, projectNodeId, nodes, edges, profileProjects, displayNames); @@ -134,11 +165,17 @@ export function parseTopicId(sessionId: string): { topicId: string; label: strin } const discussionMatch = sessionId.match(/(?:discussion|interaction)-([^-]+)$/); if (discussionMatch) { - return { topicId: `discussion:${discussionMatch[1]}`, label: `Discussion #${discussionMatch[1]}` }; + return { + topicId: `discussion:${discussionMatch[1]}`, + label: `Discussion #${discussionMatch[1]}`, + }; } const archiveMatch = sessionId.match(/archiver-(.+?)-(\d{4}-\d{2}-\d{2})-(\d)$/); if (archiveMatch) { - return { topicId: `archive:${archiveMatch[2]}-${archiveMatch[3]}`, label: `${archiveMatch[2]}-${archiveMatch[3]}` }; + return { + topicId: `archive:${archiveMatch[2]}-${archiveMatch[3]}`, + label: `${archiveMatch[2]}-${archiveMatch[3]}`, + }; } return null; } @@ -252,7 +289,11 @@ function processEntry( const ownerGroup = group === 'episode' ? inferEpisodeOwnerGroup(ownerId) : 'agent'; const ownerNodeId = `${ownerGroup}:${ownerId}`; if (!nodes.has(ownerNodeId)) { - nodes.set(ownerNodeId, { id: ownerNodeId, label: getOwnerLabel(ownerId, displayNames), group: ownerGroup }); + nodes.set(ownerNodeId, { + id: ownerNodeId, + label: getOwnerLabel(ownerId, displayNames), + group: ownerGroup, + }); } addEdge(edges, ownerNodeId, nodeId, 'authored'); if (ownerGroup === 'agent') { @@ -281,8 +322,12 @@ function processSkill( ): void { const rawId = typeof item.id === 'string' ? item.id : `skill-${cryptoRandomId()}`; const skillNodeId = `agent_skill:${rawId}`; - const sourceCaseIds = ((item.source_case_ids as string[]) ?? []).filter((id): id is string => typeof id === 'string'); - const sourceProjects = new Set(sourceCaseIds.map((id) => caseProjects.get(id)).filter((p): p is string => !!p)); + const sourceCaseIds = ((item.source_case_ids as string[]) ?? []).filter( + (id): id is string => typeof id === 'string' + ); + const sourceProjects = new Set( + sourceCaseIds.map(id => caseProjects.get(id)).filter((p): p is string => !!p) + ); const isSystem = sourceProjects.size >= 2; const parentId = isSystem ? 'system' : projectNodeId; @@ -304,7 +349,11 @@ function processSkill( if (agentId) { const agentNodeId = `agent:${agentId}`; if (!nodes.has(agentNodeId)) { - nodes.set(agentNodeId, { id: agentNodeId, label: getOwnerLabel(agentId, displayNames), group: 'agent' }); + nodes.set(agentNodeId, { + id: agentNodeId, + label: getOwnerLabel(agentId, displayNames), + group: 'agent', + }); } addEdge(edges, agentNodeId, skillNodeId, 'authored'); } @@ -341,7 +390,11 @@ function processProfile( profileProjects.get(userId)?.add(projectId); if (!nodes.has(ownerNodeId)) { - nodes.set(ownerNodeId, { id: ownerNodeId, label: getOwnerLabel(userId, displayNames), group: ownerGroup }); + nodes.set(ownerNodeId, { + id: ownerNodeId, + label: getOwnerLabel(userId, displayNames), + group: ownerGroup, + }); } addEdge(edges, ownerNodeId, profileNodeId, 'has_profile'); } @@ -369,7 +422,7 @@ function addRelatedSkillEdges( for (let j = i + 1; j < skills.length; j++) { const [idA, casesA] = skills[i]; const [idB, casesB] = skills[j]; - const overlap = [...casesA].some((id) => casesB.has(id)); + const overlap = [...casesA].some(id => casesB.has(id)); if (overlap) { addEdge(edges, idA, idB, 'related_skill'); } @@ -388,7 +441,7 @@ function addProjectShareEdges( const pidB = projectIds[j]; const usersA = projectUsers.get(pidA) ?? new Set(); const usersB = projectUsers.get(pidB) ?? new Set(); - const sharedUser = [...usersA].some((u) => usersB.has(u)); + const sharedUser = [...usersA].some(u => usersB.has(u)); if (sharedUser) { addEdge(edges, `project:${pidA}`, `project:${pidB}`, 'shares'); } @@ -396,14 +449,11 @@ function addProjectShareEdges( } } -function buildStats( - nodes: MemoryGraphNode[], - edges: MemoryGraphEdge[] -): MemoryGraphStats { +function buildStats(nodes: MemoryGraphNode[], edges: MemoryGraphEdge[]): MemoryGraphStats { const memoryGroups = ['episode', 'agent_case', 'agent_skill', 'profile'] as const; let totalMemories = 0; for (const node of nodes) { - if (memoryGroups.some((group) => group === node.group)) totalMemories++; + if (memoryGroups.some(group => group === node.group)) totalMemories++; } // 与日历时间线使用同一批已去重节点,避免同一 session 下的多条记忆被压成一条 @@ -419,7 +469,7 @@ function buildStats( totalNodes: nodes.length, totalEdges: edges.length, totalMemories, - projectCount: nodes.filter((n) => n.group === 'project').length, + projectCount: nodes.filter(n => n.group === 'project').length, activeDays: dailyMap.size, dailyGrowth, }; @@ -466,5 +516,7 @@ function stableHash(str: string): string { h1 = Math.imul(h1 ^ ch, 2654435761); h2 = Math.imul(h2 ^ ch, 1597334677); } - return ((h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0')).slice(0, 16); + return ( + (h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0') + ).slice(0, 16); } diff --git a/src/advance/classic/memory/local-model-catalog.ts b/src/advance/classic/memory/local-model-catalog.ts index b5159b1..e561678 100755 --- a/src/advance/classic/memory/local-model-catalog.ts +++ b/src/advance/classic/memory/local-model-catalog.ts @@ -1,70 +1,71 @@ -export const DEFAULT_EMBEDDING_MODEL = 'intfloat/multilingual-e5-small'; -export const DEFAULT_RERANK_MODEL = 'BAAI/bge-reranker-base'; - -export const EMBEDDING_MODELS = [ - DEFAULT_EMBEDDING_MODEL, - 'BAAI/bge-small-en-v1.5', - 'BAAI/bge-small-zh-v1.5', - 'sentence-transformers/all-MiniLM-L6-v2', -]; - -export const RERANK_MODELS = [ - DEFAULT_RERANK_MODEL, - 'BAAI/bge-reranker-v2-m3', - 'mixedbread-ai/mxbai-rerank-xsmall-v1', -]; - -/** - * 已知 embedding 模型的输出维度。 - * - * 用于向 EverOS 注入 EVEROS_EMBEDDING__DIM,使 LanceDB vector 列维度 - * 与当前模型实际输出维度一致,避免维度不匹配报错。 - */ -export const EMBEDDING_MODEL_DIMENSIONS: Record = { - [DEFAULT_EMBEDDING_MODEL]: 384, - 'BAAI/bge-small-en-v1.5': 384, - 'BAAI/bge-small-zh-v1.5': 384, - 'sentence-transformers/all-MiniLM-L6-v2': 384, -}; - -/** - * 获取指定 embedding 模型的输出维度;未知模型默认 384。 - */ -export function getEmbeddingModelDimension(model: string): number { - return EMBEDDING_MODEL_DIMENSIONS[model] ?? 384; -} - -/** - * 从 HuggingFace 拉取 embedding 模型的 config.json,解析 hidden_size 作为维度。 - * - * 对 sentence-transformers 类模型,hidden_size 通常就是输出向量维度。 - * 拉取失败时返回 null,调用方应回退到本地缓存或默认值。 - */ -export async function fetchEmbeddingModelDimension(modelId: string): Promise { - const cached = EMBEDDING_MODEL_DIMENSIONS[modelId]; - if (cached !== undefined) { - return cached; - } - - const url = `https://huggingface.co/${modelId}/raw/main/config.json`; - try { - const res = await fetch(url, { headers: { Accept: 'application/json' } }); - if (!res.ok) { - return null; - } - const config = await res.json() as { hidden_size?: number }; - if (typeof config.hidden_size === 'number' && config.hidden_size > 0) { - return config.hidden_size; - } - } catch { - // 网络失败或 JSON 解析失败都静默回退 - } - return null; -} - -// HuggingFace repo id 规则:namespace/model-name,允许字母、数字、-、_、. -const HF_MODEL_ID_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?\/[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/; - -export function isValidHuggingFaceModelId(model: string): boolean { - return HF_MODEL_ID_RE.test(model); -} +export const DEFAULT_EMBEDDING_MODEL = 'intfloat/multilingual-e5-small'; +export const DEFAULT_RERANK_MODEL = 'BAAI/bge-reranker-base'; + +export const EMBEDDING_MODELS = [ + DEFAULT_EMBEDDING_MODEL, + 'BAAI/bge-small-en-v1.5', + 'BAAI/bge-small-zh-v1.5', + 'sentence-transformers/all-MiniLM-L6-v2', +]; + +export const RERANK_MODELS = [ + DEFAULT_RERANK_MODEL, + 'BAAI/bge-reranker-v2-m3', + 'mixedbread-ai/mxbai-rerank-xsmall-v1', +]; + +/** + * 已知 embedding 模型的输出维度。 + * + * 用于向 EverOS 注入 EVEROS_EMBEDDING__DIM,使 LanceDB vector 列维度 + * 与当前模型实际输出维度一致,避免维度不匹配报错。 + */ +export const EMBEDDING_MODEL_DIMENSIONS: Record = { + [DEFAULT_EMBEDDING_MODEL]: 384, + 'BAAI/bge-small-en-v1.5': 384, + 'BAAI/bge-small-zh-v1.5': 384, + 'sentence-transformers/all-MiniLM-L6-v2': 384, +}; + +/** + * 获取指定 embedding 模型的输出维度;未知模型默认 384。 + */ +export function getEmbeddingModelDimension(model: string): number { + return EMBEDDING_MODEL_DIMENSIONS[model] ?? 384; +} + +/** + * 从 HuggingFace 拉取 embedding 模型的 config.json,解析 hidden_size 作为维度。 + * + * 对 sentence-transformers 类模型,hidden_size 通常就是输出向量维度。 + * 拉取失败时返回 null,调用方应回退到本地缓存或默认值。 + */ +export async function fetchEmbeddingModelDimension(modelId: string): Promise { + const cached = EMBEDDING_MODEL_DIMENSIONS[modelId]; + if (cached !== undefined) { + return cached; + } + + const url = `https://huggingface.co/${modelId}/raw/main/config.json`; + try { + const res = await fetch(url, { headers: { Accept: 'application/json' } }); + if (!res.ok) { + return null; + } + const config = (await res.json()) as { hidden_size?: number }; + if (typeof config.hidden_size === 'number' && config.hidden_size > 0) { + return config.hidden_size; + } + } catch { + // 网络失败或 JSON 解析失败都静默回退 + } + return null; +} + +// HuggingFace repo id 规则:namespace/model-name,允许字母、数字、-、_、. +const HF_MODEL_ID_RE = + /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?\/[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/; + +export function isValidHuggingFaceModelId(model: string): boolean { + return HF_MODEL_ID_RE.test(model); +} diff --git a/src/advance/classic/memory/local-model-service.ts b/src/advance/classic/memory/local-model-service.ts index c7b4118..4165db3 100755 --- a/src/advance/classic/memory/local-model-service.ts +++ b/src/advance/classic/memory/local-model-service.ts @@ -2,14 +2,14 @@ import { existsSync, mkdirSync, readdirSync, writeFileSync, readFileSync, statSy import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { spawn } from 'node:child_process'; -import { logger } from '../../../core/logger.js'; -import { getAppStorageDir, getLogDir } from '../../../core/platform.js'; +import { logger } from '../../core/logger.js'; +import { getAppStorageDir, getLogDir } from '../../core/platform.js'; import { ModelServer, type ModelCapability } from './model-server.js'; -import { - DEFAULT_EMBEDDING_MODEL, - DEFAULT_RERANK_MODEL, -} from './local-model-catalog.js'; -import type { LocalModelStatus, ModelServiceStatus } from '../../../electron/shared/service-status.js'; +import { DEFAULT_EMBEDDING_MODEL, DEFAULT_RERANK_MODEL } from './local-model-catalog.js'; +import type { + LocalModelStatus, + ModelServiceStatus, +} from '../../../electron/shared/service-status.js'; export interface LocalModelServiceManagerOptions { venvDir?: string; @@ -23,8 +23,18 @@ export class LocalModelServiceManager { private readonly rerankModel: string; private embeddingServer: ModelServer | null = null; private rerankServer: ModelServer | null = null; - private embeddingStatus: ModelServiceStatus = { state: 'idle', url: null, error: null, progress: null }; - private rerankStatus: ModelServiceStatus = { state: 'idle', url: null, error: null, progress: null }; + private embeddingStatus: ModelServiceStatus = { + state: 'idle', + url: null, + error: null, + progress: null, + }; + private rerankStatus: ModelServiceStatus = { + state: 'idle', + url: null, + error: null, + progress: null, + }; private starting = false; private stopping = false; @@ -121,8 +131,8 @@ export class LocalModelServiceManager { const prefix = `model-${capability}-`; const files = readdirSync(logDir) - .filter((name) => name.startsWith(prefix) && name.endsWith('.log')) - .map((name) => { + .filter(name => name.startsWith(prefix) && name.endsWith('.log')) + .map(name => { const fullPath = join(logDir, name); return { name, path: fullPath, mtime: statSync(fullPath).mtime }; }) @@ -133,7 +143,7 @@ export class LocalModelServiceManager { try { const all = readFileSync(files[0].path, 'utf-8') .split('\n') - .filter((line) => line.trim() !== ''); + .filter(line => line.trim() !== ''); return all.slice(-maxLines); } catch (err) { logger.warn({ err, capability }, '读取模型日志文件失败'); @@ -168,7 +178,7 @@ export class LocalModelServiceManager { } const libDir = join(this.venvDir, 'lib'); const entries = readdirSync(libDir, { withFileTypes: true }); - const pythonDir = entries.find((e) => e.isDirectory() && e.name.startsWith('python'))?.name; + const pythonDir = entries.find(e => e.isDirectory() && e.name.startsWith('python'))?.name; if (!pythonDir) { throw new Error(`无法定位 venv site-packages: ${libDir}`); } @@ -176,7 +186,10 @@ export class LocalModelServiceManager { } private async ensureVenv(): Promise { - const cli = join(this.venvDir, process.platform === 'win32' ? 'Scripts\\infinity_emb.exe' : 'bin/infinity_emb'); + const cli = join( + this.venvDir, + process.platform === 'win32' ? 'Scripts\\infinity_emb.exe' : 'bin/infinity_emb' + ); const stubOk = existsSync(join(this.sitePackagesDir(), 'optimum', 'bettertransformer.py')); if (existsSync(cli) && stubOk) return; @@ -236,7 +249,11 @@ __all__ = ['BetterTransformer', 'BetterTransformerManager'] logger.info({ optimumDir }, '已确保 optimum bettertransformer 占位 stub'); } - private async startCapability(capability: ModelCapability, model: string, force = false): Promise { + private async startCapability( + capability: ModelCapability, + model: string, + force = false + ): Promise { if (!force) { const existing = capability === 'embedding' ? this.embeddingServer : this.rerankServer; if (existing?.isHealthy()) return; @@ -246,7 +263,7 @@ __all__ = ['BetterTransformer', 'BetterTransformerManager'] capability, model, venvDir: this.venvDir, - onStatusChange: (status) => { + onStatusChange: status => { this.setCapabilityStatus(capability, status); }, }); @@ -285,7 +302,8 @@ __all__ = ['BetterTransformer', 'BetterTransformerManager'] } private async findPython(): Promise { - const candidates = process.platform === 'win32' ? ['python.exe', 'python3.exe'] : ['python3', 'python']; + const candidates = + process.platform === 'win32' ? ['python.exe', 'python3.exe'] : ['python3', 'python']; for (const cmd of candidates) { try { await this.runCommand(cmd, ['--version']); @@ -301,13 +319,16 @@ __all__ = ['BetterTransformer', 'BetterTransformerManager'] await new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'] }); let stderr = ''; - child.stderr?.on('data', (chunk) => { + child.stderr?.on('data', chunk => { stderr += chunk.toString(); }); child.on('error', reject); - child.on('exit', (code) => { + child.on('exit', code => { if (code === 0) resolve(); - else reject(new Error(`命令失败: ${command} ${args.join(' ')},code=${code},stderr=${stderr}`)); + else + reject( + new Error(`命令失败: ${command} ${args.join(' ')},code=${code},stderr=${stderr}`) + ); }); }); } diff --git a/src/advance/classic/memory/memory-client.ts b/src/advance/classic/memory/memory-client.ts index 4b0f36c..0530481 100755 --- a/src/advance/classic/memory/memory-client.ts +++ b/src/advance/classic/memory/memory-client.ts @@ -1,6 +1,6 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; -import { logger } from '../../../core/logger.js'; +import { logger } from '../../core/logger.js'; import type { IMemoryClient, MemoryContext, diff --git a/src/advance/classic/memory/memory-write-queue.ts b/src/advance/classic/memory/memory-write-queue.ts index b255ccf..182fe4a 100755 --- a/src/advance/classic/memory/memory-write-queue.ts +++ b/src/advance/classic/memory/memory-write-queue.ts @@ -76,11 +76,7 @@ export class MemoryWriteQueue implements IMemoryWriteQueue { */ enqueue(ctx: MemoryContext, kind: 'add_messages', messages: EverOSAddMessage[]): void; - enqueue( - ctx: MemoryContext, - kind: PendingMemoryWriteKind, - messages?: EverOSAddMessage[] - ): void { + enqueue(ctx: MemoryContext, kind: PendingMemoryWriteKind, messages?: EverOSAddMessage[]): void { const msgs = messages ?? []; const contentHash = this.computeContentHash(ctx.sessionId, kind, msgs); const id = this.buildId(ctx.projectId, ctx.sessionId, contentHash); diff --git a/src/advance/classic/memory/memory-write-retry-service.ts b/src/advance/classic/memory/memory-write-retry-service.ts index 24f77f0..761defd 100755 --- a/src/advance/classic/memory/memory-write-retry-service.ts +++ b/src/advance/classic/memory/memory-write-retry-service.ts @@ -1,4 +1,4 @@ -import { logger } from '../../../core/logger.js'; +import { logger } from '../../core/logger.js'; import { everosMemoryAddMessages, everosMemoryFlush } from './everos-api.js'; import type { MemoryContext } from './types.js'; import type { IMemoryWriteQueue, PendingMemoryWrite } from './memory-write-queue.js'; @@ -43,7 +43,7 @@ export class MemoryWriteRetryService { start(): void { if (this.timer) return; this.timer = setInterval(() => { - this.tick().catch((err) => { + this.tick().catch(err => { logger.error({ err }, 'MemoryWriteRetryService tick 异常'); }); }, this.intervalMs); @@ -71,7 +71,7 @@ export class MemoryWriteRetryService { if (this.processingIds.has(write.id)) continue; this.processingIds.add(write.id); this.retryOne(url, write) - .catch((err) => { + .catch(err => { logger.error({ err, writeId: write.id }, '重试单条记忆写入时未捕获异常'); }) .finally(() => { diff --git a/src/advance/classic/memory/model-server.ts b/src/advance/classic/memory/model-server.ts index 2702cb0..a87776d 100755 --- a/src/advance/classic/memory/model-server.ts +++ b/src/advance/classic/memory/model-server.ts @@ -4,8 +4,8 @@ import { createServer } from 'node:net'; import { join } from 'node:path'; import { existsSync, readdirSync, statSync, appendFileSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; -import { logger } from '../../../core/logger.js'; -import { getLogDir } from '../../../core/platform.js'; +import { logger } from '../../core/logger.js'; +import { getLogDir } from '../../core/platform.js'; import type { ModelServiceStatus } from '../../../electron/shared/service-status.js'; export type ModelCapability = 'embedding' | 'rerank'; @@ -98,7 +98,12 @@ export class ModelServer { private started = false; private urlValue: string | null = null; private exitHandler?: () => void; - private statusValue: ModelServiceStatus = { state: 'idle', url: null, error: null, progress: null }; + private statusValue: ModelServiceStatus = { + state: 'idle', + url: null, + error: null, + progress: null, + }; private stderrBuffer = ''; private progressTimer: NodeJS.Timeout | null = null; private expectedTotalBytes: number | null = null; @@ -158,7 +163,10 @@ export class ModelServer { ]; this.setStatus('starting'); - logger.info({ model: this.options.model, port }, `启动 ${this.options.capability} 本地模型服务`); + logger.info( + { model: this.options.model, port }, + `启动 ${this.options.capability} 本地模型服务` + ); return new Promise((resolve, reject) => { const child = spawn(cli, args, { @@ -184,7 +192,7 @@ export class ModelServer { // 同时累积 stdout 与 stderr,infinity_emb v2 会把 Uvicorn running 输出到 stderr let output = ''; - child.stdout?.on('data', (chunk) => { + child.stdout?.on('data', chunk => { const text = chunk.toString(); this.appendLog('stdout', text); output += text; @@ -192,7 +200,7 @@ export class ModelServer { this.tryParseUrl(output, resolve); }); - child.stderr?.on('data', (chunk) => { + child.stderr?.on('data', chunk => { const text = chunk.toString(); this.appendLog('stderr', text); this.stderrBuffer += text; @@ -201,13 +209,13 @@ export class ModelServer { this.tryParseUrl(output, resolve); }); - child.on('error', (err) => { + child.on('error', err => { this.setError(`启动 ${this.options.capability} 失败: ${err.message}`); this.cleanup(); reject(new Error(`启动 ${this.options.capability} 失败: ${err.message}`)); }); - child.on('exit', (code) => { + child.on('exit', code => { const tail = this.getLogs(LOG_TAIL_LINES).join('\n'); if (!this.started) { this.setError(`${this.options.capability} 进程退出 code=${code}\n最近日志:\n${tail}`); @@ -215,7 +223,11 @@ export class ModelServer { const stderrSnapshot = this.stderrBuffer; this.cleanup(); if (!this.started) { - reject(new Error(`${this.options.capability} 进程退出 code=${code}, output=${output}, stderr=${stderrSnapshot}`)); + reject( + new Error( + `${this.options.capability} 进程退出 code=${code}, output=${output}, stderr=${stderrSnapshot}` + ) + ); } }); @@ -293,7 +305,7 @@ export class ModelServer { // 先异步获取模型总大小,再开始轮询缓存目录 fetchTreeSize(modelId) - .then((total) => { + .then(total => { if (total != null && total > 0) { this.expectedTotalBytes = total; logger.debug({ modelId, totalBytes: total }, '已获取 HuggingFace 模型总大小'); @@ -363,7 +375,7 @@ export class ModelServer { // 优先从百分比进度条提取最大进度值(支持 45%、45.5%) const percentMatches = text.match(/(\d+(?:\.\d+)?)%/g); if (percentMatches) { - const maxProgress = Math.max(...percentMatches.map((m) => parseFloat(m))); + const maxProgress = Math.max(...percentMatches.map(m => parseFloat(m))); this.setStatus('downloading', Math.round(maxProgress)); return; } diff --git a/src/advance/classic/memory/recall-planner.ts b/src/advance/classic/memory/recall-planner.ts index 9e2344a..f8cc653 100755 --- a/src/advance/classic/memory/recall-planner.ts +++ b/src/advance/classic/memory/recall-planner.ts @@ -1,4 +1,4 @@ -import { logger } from '../../../core/logger.js'; +import { logger } from '../../core/logger.js'; import type { LlmClient } from '../../llm/client.js'; import type { ToolDefinition } from '../../llm/tool-types.js'; import type { IMemoryClient } from './types.js'; diff --git a/src/advance/classic/memory/remote-model-checker.ts b/src/advance/classic/memory/remote-model-checker.ts index b6a9663..f6ae112 100755 --- a/src/advance/classic/memory/remote-model-checker.ts +++ b/src/advance/classic/memory/remote-model-checker.ts @@ -1,212 +1,218 @@ -import type { EverOSConfig } from '../../config/daemon-config.js'; -import type { RemoteModelItemStatus } from '../../../electron/shared/service-status.js'; -import { formatModelShortName } from '../../../electron/shared/model-label.js'; -import { logger } from '../../../core/logger.js'; - -interface LlmConfig { - apiKey?: string; - apiUrl?: string; - provider?: 'anthropic' | 'openai'; - model?: string; - headers?: Record; -} - -const DEFAULT_BASE_URL: Record<'anthropic' | 'openai', string> = { - anthropic: 'https://api.anthropic.com/v1', - openai: 'https://api.openai.com/v1', -}; - -const OFFICIAL_HOSTS: Record<'anthropic' | 'openai', string> = { - anthropic: 'api.anthropic.com', - openai: 'api.openai.com', -}; - -function getHeaders(config: LlmConfig): Record { - if (!config.apiKey) return config.headers ?? {}; - const auth: Record = - config.provider === 'anthropic' - ? { 'x-api-key': config.apiKey, 'anthropic-version': '2023-06-01' } - : { Authorization: `Bearer ${config.apiKey}` }; - // 用户自定义 Headers 可覆盖默认 auth,方便代理/网关场景 - return { ...auth, ...(config.headers ?? {}) }; -} - -const KNOWN_ENDPOINT_SUFFIXES = ['/models', '/chat/completions']; - -export function cleanBaseUrl(url: string): string { - let cleaned = url.trim(); - if (cleaned.endsWith('/')) { - cleaned = cleaned.slice(0, -1); - } - for (const suffix of KNOWN_ENDPOINT_SUFFIXES) { - if (cleaned.toLowerCase().endsWith(suffix.toLowerCase())) { - cleaned = cleaned.slice(0, -suffix.length); - break; - } - } - if (cleaned.endsWith('/')) { - cleaned = cleaned.slice(0, -1); - } - return cleaned; -} - -function normalizeBaseUrl(provider: 'anthropic' | 'openai', apiUrl?: string): string { - if (!apiUrl) { - return DEFAULT_BASE_URL[provider]; - } - const url = cleanBaseUrl(apiUrl); - if (url.endsWith('/v1')) { - return url; - } - try { - const parsed = new URL(url); - if (parsed.hostname === OFFICIAL_HOSTS[provider] && (parsed.pathname === '' || parsed.pathname === '/')) { - return `${url}/v1`; - } - } catch { - // 非法 URL 保持原样,让后续请求报错 - } - return url; -} - -async function probeBaseUrl(baseUrl: string, headers: Record): Promise { - // 先尝试 HEAD,不下载响应体;若服务端不支持 HEAD(405),再降级为 GET - let res = await fetch(baseUrl, { method: 'HEAD', headers }); - if (res.status === 405) { - res = await fetch(baseUrl, { method: 'GET', headers }); - } - if (res.status === 401 || res.status === 403) { - throw new Error(`认证失败 HTTP ${res.status}`); - } - if (res.status >= 500) { - throw new Error(`服务端错误 HTTP ${res.status}`); - } - // 其他 2xx/3xx/4xx(除 401/403/5xx 外)均视为基地址可达 -} - -async function listModels(baseUrl: string, headers: Record): Promise { - const res = await fetch(`${baseUrl}/models`, { headers }); - if (!res.ok) { - let body = ''; - try { - body = await res.text(); - } catch { - // 忽略读取 body 失败 - } - const preview = body.length > 200 ? `${body.slice(0, 200)}...` : body; - logger.warn({ status: res.status, baseUrl, body: preview }, '远端模型 /v1/models 检测失败'); - throw new Error(`HTTP ${res.status}: ${preview || '无响应体'}`); - } - const body = (await res.json()) as { data?: Array<{ id?: string }> }; - return (body.data ?? []).map((m) => m.id ?? '').filter(Boolean); -} - -/** - * 远端模型连通性检测器。 - * - * 优先调用 `GET /v1/models` 验证模型是否存在;若该接口不可用,则降级为 - * HEAD/GET 基地址探测,整个过程不消耗 token。 - */ -export class RemoteModelChecker { - async checkLlm(config: LlmConfig): Promise { - return this.check({ - apiKey: config.apiKey, - apiUrl: config.apiUrl, - provider: config.provider ?? 'anthropic', - model: config.model, - headers: config.headers, - }); - } - - async checkMultimodal(config: EverOSConfig): Promise { - let headers: Record | undefined; - if (config.multimodalHeaders) { - try { - headers = JSON.parse(config.multimodalHeaders) as Record; - } catch { - headers = undefined; - } - } - return this.check({ - apiKey: config.multimodalApiKey, - apiUrl: config.multimodalBaseUrl, - provider: config.multimodalProvider ?? 'anthropic', - model: config.multimodalModel, - headers, - }); - } - - private async check(params: { - apiKey?: string; - apiUrl?: string; - provider: 'anthropic' | 'openai'; - model?: string; - headers?: Record; - }): Promise { - const fullModel = params.model ?? ''; - if (!params.apiKey || !fullModel) { - return { - state: 'unconfigured', - modelLabel: formatModelShortName(fullModel), - fullModel, - baseUrl: params.apiUrl ?? null, - error: null, - lastCheckedAt: Date.now(), - }; - } - - const baseUrl = normalizeBaseUrl(params.provider, params.apiUrl); - try { - const ids = await listModels(baseUrl, getHeaders(params)); - const found = ids.includes(fullModel); - if (!found) { - logger.debug({ baseUrl, fullModel, returnedIds: ids.slice(0, 20) }, '远端模型 /v1/models 返回列表未包含配置模型,但服务可达'); - } - // 只要 /v1/models 能通即认为服务可用;模型名可能是别名或自定义,不强求在列表中精确匹配 - return { - state: 'running', - modelLabel: formatModelShortName(fullModel), - fullModel, - baseUrl, - error: null, - lastCheckedAt: Date.now(), - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const statusMatch = message.match(/HTTP (\d{3})/); - const status = statusMatch ? parseInt(statusMatch[1], 10) : 0; - // /v1/models 不存在时(400/404/405),降级为探测基地址是否可达,不消耗 token - if (status === 400 || status === 404 || status === 405) { - try { - await probeBaseUrl(baseUrl, getHeaders(params)); - return { - state: 'running', - modelLabel: formatModelShortName(fullModel), - fullModel, - baseUrl, - error: null, - lastCheckedAt: Date.now(), - }; - } catch (probeErr) { - const probeMessage = probeErr instanceof Error ? probeErr.message : String(probeErr); - return { - state: 'error', - modelLabel: formatModelShortName(fullModel), - fullModel, - baseUrl, - error: probeMessage, - lastCheckedAt: Date.now(), - }; - } - } - return { - state: 'error', - modelLabel: formatModelShortName(fullModel), - fullModel, - baseUrl, - error: message, - lastCheckedAt: Date.now(), - }; - } - } -} +import type { EverOSConfig } from '../../config/daemon-config.js'; +import type { RemoteModelItemStatus } from '../../../electron/shared/service-status.js'; +import { formatModelShortName } from '../../../electron/shared/model-label.js'; +import { logger } from '../../core/logger.js'; + +interface LlmConfig { + apiKey?: string; + apiUrl?: string; + provider?: 'anthropic' | 'openai'; + model?: string; + headers?: Record; +} + +const DEFAULT_BASE_URL: Record<'anthropic' | 'openai', string> = { + anthropic: 'https://api.anthropic.com/v1', + openai: 'https://api.openai.com/v1', +}; + +const OFFICIAL_HOSTS: Record<'anthropic' | 'openai', string> = { + anthropic: 'api.anthropic.com', + openai: 'api.openai.com', +}; + +function getHeaders(config: LlmConfig): Record { + if (!config.apiKey) return config.headers ?? {}; + const auth: Record = + config.provider === 'anthropic' + ? { 'x-api-key': config.apiKey, 'anthropic-version': '2023-06-01' } + : { Authorization: `Bearer ${config.apiKey}` }; + // 用户自定义 Headers 可覆盖默认 auth,方便代理/网关场景 + return { ...auth, ...(config.headers ?? {}) }; +} + +const KNOWN_ENDPOINT_SUFFIXES = ['/models', '/chat/completions']; + +export function cleanBaseUrl(url: string): string { + let cleaned = url.trim(); + if (cleaned.endsWith('/')) { + cleaned = cleaned.slice(0, -1); + } + for (const suffix of KNOWN_ENDPOINT_SUFFIXES) { + if (cleaned.toLowerCase().endsWith(suffix.toLowerCase())) { + cleaned = cleaned.slice(0, -suffix.length); + break; + } + } + if (cleaned.endsWith('/')) { + cleaned = cleaned.slice(0, -1); + } + return cleaned; +} + +function normalizeBaseUrl(provider: 'anthropic' | 'openai', apiUrl?: string): string { + if (!apiUrl) { + return DEFAULT_BASE_URL[provider]; + } + const url = cleanBaseUrl(apiUrl); + if (url.endsWith('/v1')) { + return url; + } + try { + const parsed = new URL(url); + if ( + parsed.hostname === OFFICIAL_HOSTS[provider] && + (parsed.pathname === '' || parsed.pathname === '/') + ) { + return `${url}/v1`; + } + } catch { + // 非法 URL 保持原样,让后续请求报错 + } + return url; +} + +async function probeBaseUrl(baseUrl: string, headers: Record): Promise { + // 先尝试 HEAD,不下载响应体;若服务端不支持 HEAD(405),再降级为 GET + let res = await fetch(baseUrl, { method: 'HEAD', headers }); + if (res.status === 405) { + res = await fetch(baseUrl, { method: 'GET', headers }); + } + if (res.status === 401 || res.status === 403) { + throw new Error(`认证失败 HTTP ${res.status}`); + } + if (res.status >= 500) { + throw new Error(`服务端错误 HTTP ${res.status}`); + } + // 其他 2xx/3xx/4xx(除 401/403/5xx 外)均视为基地址可达 +} + +async function listModels(baseUrl: string, headers: Record): Promise { + const res = await fetch(`${baseUrl}/models`, { headers }); + if (!res.ok) { + let body = ''; + try { + body = await res.text(); + } catch { + // 忽略读取 body 失败 + } + const preview = body.length > 200 ? `${body.slice(0, 200)}...` : body; + logger.warn({ status: res.status, baseUrl, body: preview }, '远端模型 /v1/models 检测失败'); + throw new Error(`HTTP ${res.status}: ${preview || '无响应体'}`); + } + const body = (await res.json()) as { data?: Array<{ id?: string }> }; + return (body.data ?? []).map(m => m.id ?? '').filter(Boolean); +} + +/** + * 远端模型连通性检测器。 + * + * 优先调用 `GET /v1/models` 验证模型是否存在;若该接口不可用,则降级为 + * HEAD/GET 基地址探测,整个过程不消耗 token。 + */ +export class RemoteModelChecker { + async checkLlm(config: LlmConfig): Promise { + return this.check({ + apiKey: config.apiKey, + apiUrl: config.apiUrl, + provider: config.provider ?? 'anthropic', + model: config.model, + headers: config.headers, + }); + } + + async checkMultimodal(config: EverOSConfig): Promise { + let headers: Record | undefined; + if (config.multimodalHeaders) { + try { + headers = JSON.parse(config.multimodalHeaders) as Record; + } catch { + headers = undefined; + } + } + return this.check({ + apiKey: config.multimodalApiKey, + apiUrl: config.multimodalBaseUrl, + provider: config.multimodalProvider ?? 'anthropic', + model: config.multimodalModel, + headers, + }); + } + + private async check(params: { + apiKey?: string; + apiUrl?: string; + provider: 'anthropic' | 'openai'; + model?: string; + headers?: Record; + }): Promise { + const fullModel = params.model ?? ''; + if (!params.apiKey || !fullModel) { + return { + state: 'unconfigured', + modelLabel: formatModelShortName(fullModel), + fullModel, + baseUrl: params.apiUrl ?? null, + error: null, + lastCheckedAt: Date.now(), + }; + } + + const baseUrl = normalizeBaseUrl(params.provider, params.apiUrl); + try { + const ids = await listModels(baseUrl, getHeaders(params)); + const found = ids.includes(fullModel); + if (!found) { + logger.debug( + { baseUrl, fullModel, returnedIds: ids.slice(0, 20) }, + '远端模型 /v1/models 返回列表未包含配置模型,但服务可达' + ); + } + // 只要 /v1/models 能通即认为服务可用;模型名可能是别名或自定义,不强求在列表中精确匹配 + return { + state: 'running', + modelLabel: formatModelShortName(fullModel), + fullModel, + baseUrl, + error: null, + lastCheckedAt: Date.now(), + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const statusMatch = message.match(/HTTP (\d{3})/); + const status = statusMatch ? parseInt(statusMatch[1], 10) : 0; + // /v1/models 不存在时(400/404/405),降级为探测基地址是否可达,不消耗 token + if (status === 400 || status === 404 || status === 405) { + try { + await probeBaseUrl(baseUrl, getHeaders(params)); + return { + state: 'running', + modelLabel: formatModelShortName(fullModel), + fullModel, + baseUrl, + error: null, + lastCheckedAt: Date.now(), + }; + } catch (probeErr) { + const probeMessage = probeErr instanceof Error ? probeErr.message : String(probeErr); + return { + state: 'error', + modelLabel: formatModelShortName(fullModel), + fullModel, + baseUrl, + error: probeMessage, + lastCheckedAt: Date.now(), + }; + } + } + return { + state: 'error', + modelLabel: formatModelShortName(fullModel), + fullModel, + baseUrl, + error: message, + lastCheckedAt: Date.now(), + }; + } + } +} diff --git a/src/advance/classic/memory/secret-sanitizer.ts b/src/advance/classic/memory/secret-sanitizer.ts index 6a42812..048e29e 100755 --- a/src/advance/classic/memory/secret-sanitizer.ts +++ b/src/advance/classic/memory/secret-sanitizer.ts @@ -9,7 +9,8 @@ export class SecretSanitizer { { name: 'AWS Access Key', regex: /AKIA[0-9A-Z]{16}/g, mask: '' }, { name: 'Private Key Header', - regex: /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |OPENSSH )?PRIVATE KEY-----/g, + regex: + /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |OPENSSH )?PRIVATE KEY-----/g, mask: '', }, ]; diff --git a/src/advance/classic/provider/activity-window.ts b/src/advance/classic/provider/activity-window.ts index 97609de..51732b9 100755 --- a/src/advance/classic/provider/activity-window.ts +++ b/src/advance/classic/provider/activity-window.ts @@ -16,18 +16,12 @@ export function getCommentUpdatedAt(comment: ReviewerComment): number { /** 返回评论最近一次创建或编辑的时间。 */ export function getCommentActivityAt(comment: ReviewerComment): number { - return Math.max( - parseActivityTime(comment.createdAt), - parseActivityTime(comment.updatedAt) - ); + return Math.max(parseActivityTime(comment.createdAt), parseActivityTime(comment.updatedAt)); } /** 返回 discussion 内任一 note 最近一次创建或编辑的时间。 */ export function getDiscussionActivityAt(discussion: Discussion): number { - return discussion.notes.reduce( - (latest, note) => Math.max(latest, getCommentActivityAt(note)), - 0 - ); + return discussion.notes.reduce((latest, note) => Math.max(latest, getCommentActivityAt(note)), 0); } /** 按最近活动时间选取评论窗口,不修改输入数组。 */ diff --git a/src/advance/classic/provider/discussion-mapper.ts b/src/advance/classic/provider/discussion-mapper.ts index 9c7503a..babf531 100755 --- a/src/advance/classic/provider/discussion-mapper.ts +++ b/src/advance/classic/provider/discussion-mapper.ts @@ -20,8 +20,7 @@ export function buildDiffPosition( shaInfo: MrShaInfo ): GitLabDiffPosition | null { const diff = diffs.find( - (d) => d.newPath === finding.file || - (d.deletedFile && d.oldPath === finding.file) + d => d.newPath === finding.file || (d.deletedFile && d.oldPath === finding.file) ); if (!diff) { return null; diff --git a/src/advance/classic/provider/gitlab-provider.ts b/src/advance/classic/provider/gitlab-provider.ts index 1a2bfcd..18dbda4 100755 --- a/src/advance/classic/provider/gitlab-provider.ts +++ b/src/advance/classic/provider/gitlab-provider.ts @@ -1,7 +1,7 @@ /** * GitLab 平台 IGitProvider 实现 * - * 内部复用 src/gitlab/client.ts 的 GitLabClient, + * 内部复用 advance/gitlab/client.ts 的 GitLabClient, * 将 GitLab API 响应字段映射为 IGitProvider 统一格式。 */ @@ -19,13 +19,9 @@ import { type CiFailureReport, type CiFailedJob, } from './types.js'; -import { - selectRecentActiveComments, - selectRecentActiveDiscussions, -} from './activity-window.js'; +import { selectRecentActiveComments, selectRecentActiveDiscussions } from './activity-window.js'; import { matchesFilter } from './mr-filter.js'; -import { GitLabClient } from '../../../gitlab/client.js'; -import type { ProjectConfig } from '../../../types.js'; +import { GitLabClient } from '../../gitlab/client.js'; import type { GitlabConfig, MrReviewFilter } from '../../types.js'; /** @@ -72,22 +68,22 @@ export class GitLabProvider implements IGitProvider { async listMembers(): Promise> { const members = await this.client.listMembers(); - return members.map((m) => ({ username: m.username, name: m.name })); + return members.map(m => ({ username: m.username, name: m.name })); } async listLabels(): Promise { const labels = await this.client.listLabels(); - return labels.map((l) => l.name); + return labels.map(l => l.name); } async listProtectedBranches(): Promise { const branches = await this.client.listProtectedBranches(); - return branches.map((b) => b.name); + return branches.map(b => b.name); } async listBranches(): Promise { const branches = await this.client.listBranches(); - return branches.map((b) => b.name); + return branches.map(b => b.name); } async verify(): Promise { @@ -95,49 +91,11 @@ export class GitLabProvider implements IGitProvider { } constructor(config: GitlabConfig) { - // 构造最小化的 ProjectConfig 对象,满足 GitLabClient 构造要求 - // id / name / localPath 为占位值,gitlab 配置使用真实值 - const projectConfig: ProjectConfig = { - id: 'placeholder', - name: 'placeholder', - localPath: '/tmp/placeholder', - git: { - remote: '', - defaultBranch: 'main', - }, - gitlab: { - baseUrl: config.baseUrl, - projectPath: config.projectPath, - token: config.token, - }, - review: { - enabled: false, - schedule: '', - timezone: '', - tokenBudget: 0, - autoFix: false, - autoFixBranchPrefix: '', - rulesFile: '', - astGrepConfig: '', - filter: { - excludeAuthors: [], - excludeDrafts: false, - minChanges: 0, - maxChanges: 0, - excludePaths: [], - }, - }, - learning: { - enabled: false, - schedule: '', - patternThreshold: 3, - updateClaudeMd: false, - createAstGrepRules: false, - lookbackDays: 30, - }, - }; - - this.client = new GitLabClient(projectConfig); + this.client = new GitLabClient({ + baseUrl: config.baseUrl, + token: config.token, + projectPath: config.projectPath, + }); } /** @@ -154,7 +112,7 @@ export class GitLabProvider implements IGitProvider { // 取每个字段的第一个值利用 GitLab API 预过滤,减少传输量 if (filters) { for (const condition of filters.conditions) { - const values = condition.values.filter((v) => v.trim() !== ''); + const values = condition.values.filter(v => v.trim() !== ''); if (values.length === 0) continue; const firstValue = values[0]; switch (condition.field) { @@ -193,7 +151,7 @@ export class GitLabProvider implements IGitProvider { target_branch: apiParams.target_branch, }); - const mrs = gitlabMRs.map((mr) => ({ + const mrs = gitlabMRs.map(mr => ({ iid: mr.iid, title: mr.title, description: mr.description ?? '', @@ -206,11 +164,13 @@ export class GitLabProvider implements IGitProvider { updatedAt: mr.updated_at, webUrl: mr.web_url, assignee: (mr as unknown as { assignee?: { username: string } }).assignee?.username, - reviewers: (mr as unknown as { reviewers?: Array<{ username: string }> }).reviewers?.map((r) => r.username), + reviewers: (mr as unknown as { reviewers?: Array<{ username: string }> }).reviewers?.map( + r => r.username + ), labels: (mr as unknown as { labels?: string[] }).labels, })); - return mrs.filter((mr) => matchesFilter(mr, filters)); + return mrs.filter(mr => matchesFilter(mr, filters)); } /** @@ -218,7 +178,9 @@ export class GitLabProvider implements IGitProvider { */ async getMRShaInfo(iid: number): Promise<{ baseSha: string; headSha: string; startSha: string }> { const mr = await this.client.getMergeRequest(iid); - const refs = (mr as unknown as { diff_refs?: { base_sha: string; head_sha: string; start_sha: string } }).diff_refs; + const refs = ( + mr as unknown as { diff_refs?: { base_sha: string; head_sha: string; start_sha: string } } + ).diff_refs; if (!refs) { throw new Error(`[GitLabProvider] MR !${iid} 未返回 diff_refs`); } @@ -235,7 +197,7 @@ export class GitLabProvider implements IGitProvider { async getMRDiff(iid: number): Promise { const changes = await this.client.getMergeRequestChanges(iid); - return changes.changes.map((change) => { + return changes.changes.map(change => { const { additions, deletions } = countDiffLines(change.diff); return { @@ -286,15 +248,16 @@ export class GitLabProvider implements IGitProvider { async getDiscussionSnapshot(iid: number): Promise> { const discussions = await this.client.getMergeRequestDiscussions(iid); const all = discussions - .map((discussion) => { + .map(discussion => { const notes = discussion.notes.filter(note => !note.system); - const position = discussion.position ?? discussion.notes.find((note) => note.position)?.position; + const position = + discussion.position ?? discussion.notes.find(note => note.position)?.position; return { id: discussion.id, // 某些 GitLab 实例/版本对普通 note discussion 不返回 resolvable/resolved,默认视为可处理 resolvable: discussion.resolvable ?? true, resolved: discussion.resolved ?? false, - notes: notes.map((note) => ({ + notes: notes.map(note => ({ id: note.id, author: note.author.username, body: note.body, @@ -353,8 +316,8 @@ export class GitLabProvider implements IGitProvider { async getReviewerCommentSnapshot(iid: number): Promise { const notes = await this.client.getMergeRequestNotes(iid); const all = notes - .filter((note) => !note.system) - .map((note) => ({ + .filter(note => !note.system) + .map(note => ({ id: note.id, author: note.author.username, body: note.body, diff --git a/src/advance/classic/provider/mr-filter.ts b/src/advance/classic/provider/mr-filter.ts index c67a125..a5edd80 100755 --- a/src/advance/classic/provider/mr-filter.ts +++ b/src/advance/classic/provider/mr-filter.ts @@ -18,10 +18,10 @@ export function matchesFilter(mr: MergeRequest, filter?: MrReviewFilter): boolea if (!filter || filter.conditions.length === 0) return true; for (const condition of filter.conditions) { - const values = condition.values.filter((v) => v.trim() !== ''); + const values = condition.values.filter(v => v.trim() !== ''); if (values.length === 0) continue; - const matched = values.some((value) => matchesCondition(mr, condition.field, value)); + const matched = values.some(value => matchesCondition(mr, condition.field, value)); if (!matched) return false; } diff --git a/src/advance/classic/provider/types.ts b/src/advance/classic/provider/types.ts index 14d3dd9..69d9b51 100755 --- a/src/advance/classic/provider/types.ts +++ b/src/advance/classic/provider/types.ts @@ -265,7 +265,9 @@ export interface IGitProvider { getReviewerCommentSnapshot(iid: number): Promise; /** 获取指定 MR 的 CI 状态 */ - getCIStatus(iid: number): Promise<'pending' | 'running' | 'success' | 'failed' | 'skipped' | 'unknown'>; + getCIStatus( + iid: number + ): Promise<'pending' | 'running' | 'success' | 'failed' | 'skipped' | 'unknown'>; /** 获取指定 MR 的概览(生命周期终态感知) */ getMROverview(iid: number): Promise; diff --git a/src/advance/classic/review/reviewer-actor.ts b/src/advance/classic/review/reviewer-actor.ts index 552b16b..6719de3 100755 --- a/src/advance/classic/review/reviewer-actor.ts +++ b/src/advance/classic/review/reviewer-actor.ts @@ -53,7 +53,11 @@ export class ReviewerActor { * 发表评审 summary 评论,并按配置创建严重 finding 的 discussion threads * 返回 summary 评论的 note ID */ - async postReview(mr: MergeRequest, result: ReviewResult, options?: PostReviewOptions): Promise { + async postReview( + mr: MergeRequest, + result: ReviewResult, + options?: PostReviewOptions + ): Promise { const comment = this.getPendingDeliveryBody(options, 'summary') ?? formatReviewComment(mr, result, this.options.reviewerName); @@ -103,7 +107,13 @@ export class ReviewerActor { findings: ReviewFinding[], options?: PostReviewOptions ): Promise { - if (!options?.diffs || !options.shaInfo || !options.stateKey || !options.state || !this.options.project) { + if ( + !options?.diffs || + !options.shaInfo || + !options.stateKey || + !options.state || + !this.options.project + ) { console.log('[ReviewerActor] 缺少创建 discussion threads 的必要上下文,跳过'); return; } @@ -111,18 +121,22 @@ export class ReviewerActor { const { diffs, shaInfo, stateKey, state } = options; const posted = state.discussions[stateKey] ?? []; const severities = new Set(this.threadRiskLevels); - const candidates = findings.filter((f) => severities.has(f.severity)); + const candidates = findings.filter(f => severities.has(f.severity)); - console.log(`[ReviewerActor] MR !${mr.iid} 存在 ${candidates.length} 个 CRITICAL/HIGH finding,已发布 ${posted.length} 个`); + console.log( + `[ReviewerActor] MR !${mr.iid} 存在 ${candidates.length} 个 CRITICAL/HIGH finding,已发布 ${posted.length} 个` + ); for (const finding of findings) { if (!severities.has(finding.severity)) continue; const key = getFindingKey(finding); - if (posted.some((p) => p.findingKey === key)) continue; + if (posted.some(p => p.findingKey === key)) continue; const position = buildDiffPosition(finding, diffs, shaInfo); if (!position) { - console.warn(`[ReviewerActor] 无法为 finding ${finding.file}:${finding.line} 构造 diff position`); + console.warn( + `[ReviewerActor] 无法为 finding ${finding.file}:${finding.line} 构造 diff position` + ); continue; } @@ -137,10 +151,14 @@ export class ReviewerActor { severity: finding.severity, resolved: false, }); - console.log(`[ReviewerActor] 已为 finding ${finding.file}:${finding.line} 创建 discussion ${discussionId}`); + console.log( + `[ReviewerActor] 已为 finding ${finding.file}:${finding.line} 创建 discussion ${discussionId}` + ); } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.error(`[ReviewerActor] 创建 finding thread 失败 ${finding.file}:${finding.line}: ${message}`); + console.error( + `[ReviewerActor] 创建 finding thread 失败 ${finding.file}:${finding.line}: ${message}` + ); } } diff --git a/src/advance/classic/review/reviewer-brain.ts b/src/advance/classic/review/reviewer-brain.ts index 5a26213..fdb066d 100755 --- a/src/advance/classic/review/reviewer-brain.ts +++ b/src/advance/classic/review/reviewer-brain.ts @@ -7,7 +7,7 @@ import { formatThreadContext, type ThreadContext, } from '../utils/context-window.js'; -import { logger } from '../../../core/logger.js'; +import { logger } from '../../core/logger.js'; import { extractJsonText } from '../utils/json-extraction.js'; import { defaultPromptLoader, type PromptLoader } from '../../llm/prompts/loader.js'; diff --git a/src/advance/classic/role-node-runtime.ts b/src/advance/classic/role-node-runtime.ts new file mode 100644 index 0000000..5d2317d --- /dev/null +++ b/src/advance/classic/role-node-runtime.ts @@ -0,0 +1,260 @@ +/** + * Role 节点运行时:管线中 role.* 节点的执行载体。 + * + * 每个 (项目, 角色) 节点实例对应一个长驻子进程(fork role-entry), + * 触发时通过 IPC 发送 run 指令、等待 done/error,替代旧版 + * "全局角色进程 + 项目轮询" 模型。子进程崩溃后在下次触发时惰性重建。 + */ + +import { fork, type ChildProcess } from 'node:child_process'; +import { logger } from '../core/logger.js'; +import type { Project, Role } from '../types.js'; + +export interface RoleNodeRuntimeOptions { + /** role-entry.js 的路径 */ + runnerPath: string; + /** SQLite 元数据库路径(延迟取值:daemon 构造期间 context 尚未补全) */ + getDbPath: () => string; + /** 守护进程配置(LLM 参数注入子进程) */ + getDaemonConfig: () => { + apiKey?: string; + provider?: string; + model?: string; + apiUrl?: string; + headers?: Record | string; + llmRequestsPerMinute?: number; + }; + /** EverOS MCP URL(启动子进程前必须就绪) */ + getMcpUrl: () => string | null; + /** CodeGraph Server URL */ + getCodeGraphUrl: () => string | null; + /** 等待子进程 ready 的超时(毫秒),默认 30s;测试可注入更短值 */ + readyTimeoutMs?: number; +} + +interface InstanceEntry { + child: ChildProcess; + ready: Promise; + running: boolean; +} + +/** 等待子进程 ready 的超时 */ +const READY_TIMEOUT_MS = 30_000; + +export class RoleNodeRuntime { + private instances = new Map(); + + constructor(private readonly options: RoleNodeRuntimeOptions) {} + + /** + * 触发一个 (项目, 角色) 节点实例执行单次循环。 + * 子进程不存在/已退出时惰性 fork;执行期间(含 ready 等待窗口)重入会被跳过, + * 等价于旧版"上一次未完成则跳过"。 + */ + async runOnce(project: Project, role: Role): Promise { + const key = instanceKey(project.id, role); + let entry = this.instances.get(key); + if (entry?.running) { + logger.warn(`[RoleNode] ${key} 上一次执行尚未完成,跳过本次触发`); + return; + } + if (!entry) { + entry = await this.spawn(project, role); + this.instances.set(key, entry); + } + // running 置位必须先于 ready 等待,堵住冷启动窗口的双派发 + entry.running = true; + try { + await entry.ready; + await this.dispatchRun(entry.child, key); + } catch (error) { + // ready 失败/执行期子进程异常:清表,下次触发重建 + if (this.instances.get(key) === entry) { + this.instances.delete(key); + } + throw error; + } finally { + entry.running = false; + } + } + + /** 停止指定 (项目, 角色) 实例(重启语义:下次触发时重新 fork);3 秒未退出则 SIGKILL */ + stopInstance(projectId: string, role: Role): void { + const key = instanceKey(projectId, role); + const entry = this.instances.get(key); + if (entry) { + this.instances.delete(key); + terminateWithFallback(entry.child, key); + } + } + + /** 停止某角色的全部实例(角色服务停止语义) */ + stopRole(role: Role): void { + for (const [key, entry] of this.instances) { + if (key.endsWith(`:${role}`)) { + this.instances.delete(key); + terminateWithFallback(entry.child, key); + } + } + } + + /** 停止某项目的全部节点实例(项目注销语义) */ + stopProject(projectId: string): void { + for (const [key, entry] of this.instances) { + if (key.startsWith(`${projectId}:`)) { + this.instances.delete(key); + terminateWithFallback(entry.child, key); + } + } + } + + /** 停止全部实例(daemon 关闭语义) */ + stopAll(): void { + for (const [key, entry] of this.instances) { + terminateWithFallback(entry.child, key); + } + this.instances.clear(); + } + + /** 某角色当前存活的节点实例数 */ + countInstances(role: Role): number { + let count = 0; + for (const key of this.instances.keys()) { + if (key.endsWith(`:${role}`)) count += 1; + } + return count; + } + + private async spawn(project: Project, role: Role): Promise { + const daemonConfig = this.options.getDaemonConfig(); + const child = fork(this.options.runnerPath, [], { + env: { + ...process.env, + ROLE: role, + CK_PROJECT_ID: project.id, + CK_DB_PATH: this.options.getDbPath(), + CK_EVEROS_MCP_URL: this.options.getMcpUrl() ?? '', + CK_CODEGRAPH_SERVER_URL: this.options.getCodeGraphUrl() ?? '', + CK_LLM_API_KEY: daemonConfig.apiKey ?? '', + CK_LLM_PROVIDER: daemonConfig.provider ?? 'anthropic', + CK_LLM_MODEL: daemonConfig.model ?? '', + CK_LLM_API_URL: daemonConfig.apiUrl ?? '', + CK_LLM_HEADERS: + typeof daemonConfig.headers === 'string' + ? daemonConfig.headers + : JSON.stringify(daemonConfig.headers ?? {}), + CK_LLM_RPM: String(daemonConfig.llmRequestsPerMinute ?? 10), + }, + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + }); + + child.stdout?.on('data', data => { + logger.info({ role, project: project.id, output: String(data).trim() }, '[Role Node]'); + }); + child.stderr?.on('data', data => { + logger.warn({ role, project: project.id, output: String(data).trim() }, '[Role Node]'); + }); + + const key = instanceKey(project.id, role); + const entry: InstanceEntry = { + child, + running: false, + ready: new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + // 超时必须终止并清表,避免残留实例反复 await 已 reject 的 ready + this.instances.delete(key); + child.kill('SIGKILL'); + reject(new Error(`[RoleNode] ${key} 等待 ready 超时`)); + }, this.options.readyTimeoutMs ?? READY_TIMEOUT_MS); + const onMessage = (message: unknown) => { + if (isNodeMessage(message) && message.type === 'ready') { + cleanup(); + resolve(); + } + }; + // 子进程启动即崩时立即 reject,不掩盖真实错误 + const onExit = (code: number | null) => { + cleanup(); + reject(new Error(`[RoleNode] ${key} 子进程在 ready 前退出,code=${code}`)); + }; + const cleanup = () => { + clearTimeout(timer); + child.off('message', onMessage); + child.off('exit', onExit); + }; + child.on('message', onMessage); + child.on('exit', onExit); + }), + }; + + child.on('exit', code => { + logger.info(`[RoleNode] ${key} 子进程退出,code=${code}`); + // 崩溃后清表,下次触发时惰性重建 + if (this.instances.get(key) === entry) { + this.instances.delete(key); + } + }); + + return entry; + } + + private dispatchRun(child: ChildProcess, key: string): Promise { + return new Promise((resolve, reject) => { + const onMessage = (message: unknown) => { + if (!isNodeMessage(message)) return; + if (message.type === 'done') { + cleanup(); + resolve(); + } else if (message.type === 'error') { + cleanup(); + reject(new Error(message.message)); + } + }; + const onExit = (code: number | null) => { + cleanup(); + reject(new Error(`[RoleNode] ${key} 执行期间子进程退出,code=${code}`)); + }; + const cleanup = () => { + child.off('message', onMessage); + child.off('exit', onExit); + }; + child.on('message', onMessage); + child.on('exit', onExit); + child.send({ type: 'run' }, (error: Error | null) => { + if (error) { + cleanup(); + reject(error); + } + }); + }); + } +} + +function instanceKey(projectId: string, role: Role): string { + return `${projectId}:${role}`; +} + +/** SIGTERM 后 3 秒未退出则 SIGKILL(对齐旧版 restartProject 的兜底) */ +function terminateWithFallback(child: ChildProcess, key: string): void { + const timer = setTimeout(() => { + logger.warn(`[RoleNode] ${key} 子进程未在 3 秒内退出,强制结束`); + child.kill('SIGKILL'); + }, 3000); + child.once('exit', () => clearTimeout(timer)); + child.kill('SIGTERM'); +} + +interface NodeMessage { + type: 'ready' | 'done' | 'error'; + message?: string; +} + +function isNodeMessage(value: unknown): value is NodeMessage { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + typeof (value as { type: unknown }).type === 'string' + ); +} diff --git a/src/advance/classic/role-service-registry.ts b/src/advance/classic/role-service-registry.ts deleted file mode 100755 index e70c11e..0000000 --- a/src/advance/classic/role-service-registry.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { Role } from '../types.js'; -import type { HandlerContext } from '../ipc/handlers.js'; -import { RoleService, type RoleServiceOptions, type RoleServiceStatus } from './role-service.js'; - -/** - * 角色服务注册表 - * 统一管理多角色服务的生命周期(注册、启动、停止、重启、状态查询) - */ -export class RoleServiceRegistry { - private services = new Map(); - private registeredRoles = new Set(); - public context: HandlerContext; - - constructor( - context: HandlerContext, - private runnerPath: string, - private options: RoleServiceOptions = {}, - ) { - this.context = context; - } - - /** - * 注册指定角色的服务 - * @param role 角色标识 - */ - register(role: Role): void { - this.registeredRoles.add(role); - } - - /** - * 启动指定角色的服务 - * 若 Daemon 正在初始化 EverOS,会等待 MCP URL 就绪后再 fork Agent 子进程。 - * @param role 角色标识 - */ - async start(role: Role): Promise { - await this.waitForMemoryMcpUrl(60000); - await this.waitForCodeGraphUrl(60000); - await this.getService(role).start(); - } - - /** - * 等待 Daemon 设置 EverOS MCP URL,最多等待 timeoutMs 毫秒。 - */ - private async waitForMemoryMcpUrl(timeoutMs = 60000): Promise { - const start = Date.now(); - while (!this.options.mcpUrl) { - if (Date.now() - start > timeoutMs) { - throw new Error('等待 EverOS MCP URL 超时,无法启动角色服务'); - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - } - - /** 等待 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 角色标识 - */ - async stop(role: Role): Promise { - await this.getService(role).stop(); - } - - /** - * 重启指定角色的服务或指定项目 - * @param role 角色标识 - * @param projectId 项目 ID(可选) - */ - async restartProject(role: Role, projectId: string): Promise { - await this.getService(role).restartProject(projectId); - } - - /** - * 获取指定角色服务的运行状态 - * @param role 角色标识 - * @returns 角色服务状态 - */ - getStatus(role: Role): RoleServiceStatus { - return this.getService(role).getStatus(); - } - - /** - * 设置 EverOS MCP Server URL,供后续懒创建的角色服务使用, - * 同时回传给已经创建过的服务实例,避免它们因缓存了旧 options 而重复启动 EverOS。 - */ - setMemoryMcpUrl(url: string): void { - this.options = { ...this.options, mcpUrl: url }; - for (const service of this.services.values()) { - service.setMcpUrl(url); - } - } - - /** 设置 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 角色标识 - * @returns RoleService 实例 - * @throws 若角色未注册则抛出错误 - */ - private getService(role: Role): RoleService { - let service = this.services.get(role); - if (!service) { - if (!this.registeredRoles.has(role)) { - throw new Error(`角色 ${role} 服务未注册`); - } - // 懒创建:确保此时 context 已被回设为完整的 handlerContext - service = new RoleService(role, this.context, this.runnerPath, this.options); - this.services.set(role, service); - } - return service; - } -} diff --git a/src/advance/classic/role-service.ts b/src/advance/classic/role-service.ts deleted file mode 100755 index 67469e3..0000000 --- a/src/advance/classic/role-service.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { fork, type ChildProcess } from 'node:child_process'; -import { join } from 'node:path'; -import { logger } from '../../core/logger.js'; -import type { Role } from '../types.js'; -import type { HandlerContext } from '../ipc/handlers.js'; -import { EverOSService } from './memory/everos-service.js'; -import { EverOSMcpServer } from './memory/everos-mcp-server.js'; - -export interface RoleServiceOptions { - /** EverOS MCP Server URL;若提供则不再本地启动 EverOS */ - mcpUrl?: string; - /** EverOS submodule 路径 */ - submodulePath?: string; - /** EverOS 数据目录 */ - everosDataDir?: string; - /** Daemon 托管的 CodeGraph Server URL */ - codeGraphUrl?: string; -} - -/** - * 角色服务运行状态 - */ -export interface RoleServiceStatus { - /** 服务是否正在运行 */ - running: boolean; - /** 该角色启用的项目数量 */ - enabledProjects: number; - /** 正在运行的项目 ID 列表 */ - runningProjects: string[]; -} - -/** - * 按角色实例化的服务管理器 - * 负责为单个角色 fork 子进程并管理其生命周期 - */ -export class RoleService { - private child: ChildProcess | null = null; - private everosService: EverOSService | null = null; - private mcpServer: EverOSMcpServer | null = null; - private mcpUrl: string | null = null; - - constructor( - private role: Role, - private context: HandlerContext, - private runnerPath: string, - private options: RoleServiceOptions = {}, - ) {} - - /** - * 启动角色服务 - * 总管服务始终 fork 子进程,由子进程周期性读取数据库中的启用项目, - * 动态启动或停止对应项目的 Agent 循环。 - * 即使当前没有启用项目,监控服务本身也应保持运行,以便后续动态响应项目启用/禁用变化。 - */ - async start(): Promise { - if (this.child) { - logger.warn(`角色 ${this.role} 服务已在运行`); - return; - } - - if (!this.mcpUrl) { - this.mcpUrl = this.options.mcpUrl ?? (await this.startMemoryInfrastructure()); - } - - const child = fork(this.runnerPath, [], { - env: { - ...process.env, - 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 ?? '', - CK_LLM_API_URL: this.context.getDaemonConfig?.().apiUrl ?? '', - CK_LLM_HEADERS: this.context.getDaemonConfig?.().headers ?? '{}', - CK_LLM_RPM: String(this.context.getDaemonConfig?.().llmRequestsPerMinute ?? 10), - }, - stdio: 'pipe', - }); - this.child = child; - - child.stdout?.on('data', (data) => { - logger.info({ role: this.role, output: data.toString().trim() }, '[Role Agent]'); - }); - child.stderr?.on('data', (data) => { - logger.warn({ role: this.role, output: data.toString().trim() }, '[Role Agent]'); - }); - - child.on('exit', (code) => { - logger.info(`角色 ${this.role} 子进程退出,code=${code}`); - // 防止旧子进程的 exit 事件在 restart 后把新子进程引用清空 - if (this.child === child) { - this.child = null; - } - }); - } - - /** - * 更新 EverOS MCP URL,供 Registry 在 Daemon 启动 EverOS 后回传给已缓存的服务实例。 - */ - setMcpUrl(url: string): void { - this.options.mcpUrl = url; - } - - /** 更新 CodeGraph Server URL,供重启后的角色子进程继续复用统一服务。 */ - setCodeGraphUrl(url: string): void { - this.options.codeGraphUrl = url; - } - - /** - * 停止角色服务 - */ - async stop(): Promise { - if (this.child) { - this.child.kill('SIGTERM'); - this.child = null; - } - await this.mcpServer?.stop(); - this.everosService?.stop(); - this.mcpServer = null; - this.everosService = null; - this.mcpUrl = null; - } - - /** - * 重启指定项目的服务 - * 当前实现:停止并重启整个角色服务,等待旧子进程退出后再启动新子进程, - * 避免数据库锁或端口等资源冲突。 - */ - async restartProject(_projectId: string): Promise { - if (!this.child) { - await this.start(); - return; - } - - const oldChild = this.child; - // 先清空引用,防止旧子进程的 exit 事件误清掉后续新子进程 - this.child = null; - oldChild.kill('SIGTERM'); - - // 等待旧子进程退出,最多 3 秒;超时则强制结束 - await new Promise((resolve) => { - const timer = setTimeout(() => { - logger.warn(`角色 ${this.role} 旧子进程未在 3 秒内退出,强制结束`); - oldChild.kill('SIGKILL'); - resolve(); - }, 3000); - oldChild.on('exit', () => { - clearTimeout(timer); - resolve(); - }); - }); - - await this.start(); - } - - /** - * 获取角色服务当前状态 - */ - getStatus(): RoleServiceStatus { - const enabledProjects = this.context.store.getRoleEnabledProjects(this.role); - return { - running: this.child !== null, - enabledProjects: enabledProjects.length, - runningProjects: enabledProjects.map((p) => p.id), - }; - } - - private async startMemoryInfrastructure(): Promise { - const submodulePath = this.options.submodulePath ?? join(process.cwd(), 'vendor', 'everos'); - this.everosService = new EverOSService({ submodulePath, dataDir: this.options.everosDataDir }); - const everosUrl = await this.everosService.start(); - - this.mcpServer = new EverOSMcpServer({ everosUrl }); - return this.mcpServer.start(); - } -} diff --git a/src/advance/classic/runners/archiver-runner.ts b/src/advance/classic/runners/archiver-runner.ts index 46d5690..adc1f98 100755 --- a/src/advance/classic/runners/archiver-runner.ts +++ b/src/advance/classic/runners/archiver-runner.ts @@ -9,9 +9,7 @@ import { MemoryClient } from '../memory/memory-client.js'; import type { Project, RoleConfig } from '../../types.js'; import type { LlmClient } from '../../llm/client.js'; import { getArchiveRoot, isRoleConfigEnabled } from '../../types.js'; -import { - normalizeArchiverConfig, -} from '../../archiver/provider-config.js'; +import { normalizeArchiverConfig } from '../../archiver/provider-config.js'; import { type ArchiverProviderCoordinator, type ArchiverProviderExecution, @@ -113,10 +111,6 @@ export class ArchiverRunner extends BaseRoleRunner { return 'archiver'; } - protected getDefaultSchedule(): string { - return '0 2 * * *'; - } - /** * Archiver 不需要 GitLab 配置,只要本地项目目录存在即可 */ diff --git a/src/advance/classic/runners/base-role-runner.ts b/src/advance/classic/runners/base-role-runner.ts index 3f5ec30..2dcade8 100755 --- a/src/advance/classic/runners/base-role-runner.ts +++ b/src/advance/classic/runners/base-role-runner.ts @@ -1,19 +1,13 @@ -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, - getRoleConfigSchedule, - isRoleConfigEnabled, -} from '../../types.js'; +import { getArchiveRoot, isRoleConfigEnabled } from '../../types.js'; import { loadSoulContent, type SoulContent } from '../soul/soul-loader.js'; import { loadProjectContext } from '../context/project-context-loader.js'; import { recordProjectError, clearProjectError, recordProjectMissingToken, - recordAgentStarted, } from '../status/project-status-store.js'; import type { ProjectConfig, IRoleRunner } from './role-runner.js'; @@ -26,17 +20,15 @@ export interface BaseRoleRunnerOptions { * 角色 Runner 抽象基类 * * 统一约束所有角色 Runner 的公共生命周期: - * - 启动/停止项目循环 - * - cron 调度 - * - 运行锁(前一次未完成则跳过) + * - 单次项目执行(runProjectOnce),含运行锁(重入跳过) * - GitLab Token / 本地目录预检查 * - 项目错误状态记录 * - * 子类只需实现 runProject() 方法,专注于业务逻辑。 + * 调度由 daemon 侧 PipelineScheduler 负责(管线 trigger.cron 节点), + * Runner 不再自持 cron 循环。 */ export abstract class BaseRoleRunner implements IRoleRunner { protected readonly llmClient: LlmClient; - private activeLoops = new Map>(); private runningProjects = new Set(); constructor(options: BaseRoleRunnerOptions) { @@ -49,69 +41,21 @@ export abstract class BaseRoleRunner implements IRoleRunner { protected abstract getRole(): Role; /** - * 默认的 cron 调度表达式 - */ - protected abstract getDefaultSchedule(): string; - - /** - * 启动指定项目的角色循环 + * 执行单次项目循环;若该项目前一次仍在运行则跳过(运行锁) */ - async startProjectLoop(project: ProjectConfig): Promise { + async runProjectOnce(project: ProjectConfig): Promise { const fullProject = project as unknown as Project; - const config = this.getRoleConfig(fullProject); - - if (!isRoleConfigEnabled(config)) { - console.log(`[${this.getRoleName()}] 项目 ${fullProject.name} 未启用,跳过`); - return; - } - - const scheduleExpr = getRoleConfigSchedule(config)?.trim() || this.getDefaultSchedule(); - if (!validateCron(scheduleExpr)) { - const message = `[${this.getRoleName()}] 项目 ${fullProject.name} 的 reviewSchedule "${scheduleExpr}" 不是合法的 cron 表达式`; - console.error(message); - recordProjectError(fullProject, new Error(message), 'unknown'); - return; - } - - recordAgentStarted(fullProject); - - // 立即执行一次 - await this.runOnce(fullProject); - - // 按 schedule 定时执行,若前一次未完成则跳过 - const job = schedule(scheduleExpr, () => { - void this.runOnce(fullProject); - }); - - this.activeLoops.set(fullProject.id, job); - console.log(`[${this.getRoleName()}] 项目 ${fullProject.name} 已启动定时循环: ${scheduleExpr}`); - } - - /** - * 停止指定项目的角色循环 - */ - stopProjectLoop(projectId: string): void { - const job = this.activeLoops.get(projectId); - if (job) { - job.stop(); - this.activeLoops.delete(projectId); - console.log(`[${this.getRoleName()}] 项目 ${projectId} 定时循环已停止`); - } - } - - /** - * 执行单次循环;若前一次仍在运行则跳过 - */ - private async runOnce(project: Project): Promise { - if (this.runningProjects.has(project.id)) { - console.log(`[${this.getRoleName()}] 项目 ${project.name} 的上一次循环尚未完成,跳过本次调度`); + if (this.runningProjects.has(fullProject.id)) { + console.log( + `[${this.getRoleName()}] 项目 ${fullProject.name} 的上一次循环尚未完成,跳过本次调度` + ); return; } - this.runningProjects.add(project.id); + this.runningProjects.add(fullProject.id); try { - await this.runProjectSafely(project); + await this.runProjectSafely(fullProject); } finally { - this.runningProjects.delete(project.id); + this.runningProjects.delete(fullProject.id); } } diff --git a/src/advance/classic/runners/maintainer-runner.ts b/src/advance/classic/runners/maintainer-runner.ts index 6716497..50319bd 100755 --- a/src/advance/classic/runners/maintainer-runner.ts +++ b/src/advance/classic/runners/maintainer-runner.ts @@ -7,6 +7,8 @@ */ import { LlmClient } from '../../llm/client.js'; +import { LlmMaintainerLocalJudge } from '../fix/maintainer-llm-judge.js'; +import type { MaintainerLocalJudge } from '../fix/maintainer-local-judge.js'; import { GitLabProvider } from '../provider/gitlab-provider.js'; import { WorktreeManager } from '../worktree/worktree-manager.js'; import { MaintainerBrain } from '../fix/maintainer-brain.js'; @@ -28,7 +30,7 @@ import type { } from '../provider/types.js'; import type { MrContext } from '../fix/cognitive-types.js'; import { buildAuthenticatedRemoteUrl } from './shared/config-utils.js'; -import { logger } from '../../../core/logger.js'; +import { logger } from '../../core/logger.js'; import { loadState, saveState, @@ -505,18 +507,18 @@ const CONTINUE_AFTER_CI: CiHandlingResult = { }; export class MaintainerRunner extends BaseRoleRunner { + /** 本地判别辅助(目前为保守桩,未来可替换为本地轻量模型实现) */ + private localJudge: MaintainerLocalJudge; + constructor(options: MaintainerRunnerOptions) { super({ llmClient: options.llmClient }); + this.localJudge = new LlmMaintainerLocalJudge(this.llmClient); } protected getRole(): 'maintainer' { return 'maintainer'; } - protected getDefaultSchedule(): string { - return '*/10 * * * *'; - } - /** * 对单个项目执行 MR 维护轮询 */ @@ -2855,7 +2857,24 @@ export class MaintainerRunner extends BaseRoleRunner { } try { - return await brain.recheckAlreadyFixed(finding); + const baseResult = await brain.recheckAlreadyFixed(finding); + + const assist = await this.localJudge.assistAlreadyFixedCheck( + finding.message, + focusedContextToString(focusedContent) + ); + const assistConfirmedFixed = + ('kind' in assist && assist.kind === 'reliable' && assist.value) || + (!('kind' in assist) && assist.likelyAlreadyFixed); + if (assistConfirmedFixed) { + return { + alreadyFixed: true, + reason: assist.reason, + evidence: 'evidence' in assist ? assist.evidence : undefined, + }; + } + + return baseResult; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn( diff --git a/src/advance/classic/runners/reviewer-runner.ts b/src/advance/classic/runners/reviewer-runner.ts index a926804..1814ef8 100755 --- a/src/advance/classic/runners/reviewer-runner.ts +++ b/src/advance/classic/runners/reviewer-runner.ts @@ -42,7 +42,7 @@ import type { ReviewerComment, } from '../provider/types.js'; import { BaseRoleRunner } from './base-role-runner.js'; -import { logger } from '../../../core/logger.js'; +import { logger } from '../../core/logger.js'; import { getCommentActivityAt } from '../provider/activity-window.js'; import { ArchiverProjectKnowledgeSource, @@ -73,10 +73,6 @@ export class ReviewerRunner extends BaseRoleRunner { return 'reviewer'; } - protected getDefaultSchedule(): string { - return '*/10 * * * *'; - } - /** * 对单个项目执行 MR 评审轮询 */ diff --git a/src/advance/classic/runners/role-runner.ts b/src/advance/classic/runners/role-runner.ts index 9420078..97f0dfc 100755 --- a/src/advance/classic/runners/role-runner.ts +++ b/src/advance/classic/runners/role-runner.ts @@ -16,20 +16,17 @@ export interface ProjectConfig { /** * 角色 Runner 统一接口 - * 每个角色实现此接口以提供项目级循环逻辑 + * 每个角色实现此接口以提供项目级单次执行逻辑 + * + * 调度由 daemon 侧 PipelineScheduler(管线 trigger.cron 节点)负责, + * Runner 只响应"执行一次"。 */ export interface IRoleRunner { /** - * 启动指定项目的角色循环 + * 执行指定项目的单次角色循环 * @param project - 项目配置 */ - startProjectLoop(project: ProjectConfig): Promise; - - /** - * 停止指定项目的角色循环 - * @param projectId - 项目唯一标识 - */ - stopProjectLoop(projectId: string): void; + runProjectOnce(project: ProjectConfig): Promise; } export interface CreateRoleRunnerOptions { diff --git a/src/advance/classic/runners/shared/review-utils.ts b/src/advance/classic/runners/shared/review-utils.ts index aee78db..0f68d51 100755 --- a/src/advance/classic/runners/shared/review-utils.ts +++ b/src/advance/classic/runners/shared/review-utils.ts @@ -45,7 +45,7 @@ export function groupFindingsBySeverity( export function formatSummary(summary: string): string[] { let formatted = summary.replace(/(\d+)\)\s*/g, '$1. '); formatted = formatted.replace(/([^\n])(\d+\.\s)/g, '$1\n$2'); - return formatted.split('\n').map((line) => `> ${line}`); + return formatted.split('\n').map(line => `> ${line}`); } /** @@ -140,7 +140,11 @@ export function formatFindingDiscussionBody(finding: ReviewFinding, agentName?: * * 仅用于 reviewer 角色,汇总所有 findings。 */ -export function formatReviewComment(mr: MergeRequest, result: ReviewResult, agentName?: string): string { +export function formatReviewComment( + mr: MergeRequest, + result: ReviewResult, + agentName?: string +): string { const severityOrder: ReviewFinding['severity'][] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; const groups = groupFindingsBySeverity(result.findings); const total = result.findings.length; @@ -183,7 +187,11 @@ export function formatReviewComment(mr: MergeRequest, result: ReviewResult, agen * * 当 MR 有新 commit 或发现新问题时,在原 summary 下追加一条补充说明。 */ -export function formatSupplementaryReviewComment(mr: MergeRequest, newFindings: ReviewFinding[], agentName?: string): string { +export function formatSupplementaryReviewComment( + mr: MergeRequest, + newFindings: ReviewFinding[], + agentName?: string +): string { const severityOrder: ReviewFinding['severity'][] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; const groups = groupFindingsBySeverity(newFindings); const total = newFindings.length; diff --git a/src/advance/classic/soul/soul-loader.ts b/src/advance/classic/soul/soul-loader.ts index 86ec9c1..5ed9b07 100755 --- a/src/advance/classic/soul/soul-loader.ts +++ b/src/advance/classic/soul/soul-loader.ts @@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { getProjectSoulsDir } from '../../../core/platform.js'; +import { getProjectSoulsDir } from '../../core/platform.js'; import type { Project, Role } from '../../types.js'; export interface SoulContent { @@ -61,7 +61,7 @@ export function loadSoulContent(project: Project, role: Role): SoulContent { export async function saveSoulContent( project: Project, role: Role, - content: string, + content: string ): Promise { const sourcePath = getSoulPath(project, role); mkdirSync(dirname(sourcePath), { recursive: true }); diff --git a/src/advance/classic/stage-handlers.ts b/src/advance/classic/stage-handlers.ts new file mode 100644 index 0000000..00eb9a9 --- /dev/null +++ b/src/advance/classic/stage-handlers.ts @@ -0,0 +1,52 @@ +/** + * 角色子图的 stage 处理器(M7 钻取层,role-entry 子进程侧)。 + * + * - stage.role-run:复合 stage,包装既有 Runner 的完整单次循环(黑箱不变) + * - stage.ast-grep:ast-grep 预检(advisory),对 params.paths 指定的文件扫描 + * + * Role 节点带 subgraph 时,role-entry 用本注册表执行子图而非整轮 runProjectOnce; + * 默认子图(画布"展开为子图"生成)只含单个 stage.role-run,行为与黑箱完全一致。 + */ + +import { isAbsolute, join } from 'node:path'; +import type { NodeHandler } from '../pipeline/core/types.js'; +import type { Project } from '../types.js'; +import type { IRoleRunner } from './runners/role-runner.js'; +import { runAstGrepPrecheck } from '../ast-grep/runner.js'; + +export interface StageHandlerDeps { + project: Project; + runner: IRoleRunner; +} + +export function createRoleStageHandlers(deps: StageHandlerDeps): Map { + const handlers = new Map(); + + handlers.set('stage.role-run', { + type: 'stage.role-run', + run: async () => { + await deps.runner.runProjectOnce(deps.project); + return { done: true }; + }, + }); + + handlers.set('stage.ast-grep', { + type: 'stage.ast-grep', + run: async (_ctx, _inputs, params) => { + // 相对路径按项目根归一(子进程 CWD 继承自 daemon,不可依赖) + const paths = Array.isArray(params.paths) + ? params.paths + .filter((p): p is string => typeof p === 'string') + .map(p => (isAbsolute(p) ? p : join(deps.project.rootPath, p))) + : []; + const configPath = + typeof params.config === 'string' + ? params.config + : join(deps.project.rootPath, '.codekeeper', 'sgconfig.yml'); + const result = await runAstGrepPrecheck(configPath, paths); + return { precheck: result, findingsCount: result.findings.length }; + }, + }); + + return handlers; +} diff --git a/src/advance/classic/state/mr-state-store.ts b/src/advance/classic/state/mr-state-store.ts index 2dbdee9..9f1741c 100755 --- a/src/advance/classic/state/mr-state-store.ts +++ b/src/advance/classic/state/mr-state-store.ts @@ -8,9 +8,7 @@ import type { MetadataStore } from '../../store/metadata-store'; /** * 允许局部更新的字段类型(排除不可变字段) */ -export type MrStatePatch = Partial< - Omit ->; +export type MrStatePatch = Partial>; /** * MR 状态存储封装类 @@ -40,7 +38,9 @@ export class MrStateStore { * 按 updatedAt 降序排列 */ listByProject(projectId: string): MrReviewState[] { - return this.store.listMrStatesByProject(projectId).filter((s): s is MrReviewState => s !== undefined); + return this.store + .listMrStatesByProject(projectId) + .filter((s): s is MrReviewState => s !== undefined); } /** diff --git a/src/advance/classic/status/project-status-store.ts b/src/advance/classic/status/project-status-store.ts index 76ffe41..87cfb9a 100755 --- a/src/advance/classic/status/project-status-store.ts +++ b/src/advance/classic/status/project-status-store.ts @@ -12,14 +12,10 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; -import { getProjectAgentStatusDir } from '../../../core/platform.js'; +import { getProjectAgentStatusDir } from '../../core/platform.js'; import type { Project } from '../../types.js'; -export type MrAgentProjectErrorType = - | 'missing-token' - | 'invalid-token' - | 'gitlab-api' - | 'unknown'; +export type MrAgentProjectErrorType = 'missing-token' | 'invalid-token' | 'gitlab-api' | 'unknown'; export interface MrAgentProjectStatus { /** 最近一次错误 */ @@ -68,10 +64,7 @@ export function loadProjectStatus(project: Project): MrAgentProjectStatus { /** * 写入项目 MR Agent 状态 */ -export function saveProjectStatus( - project: Project, - status: MrAgentProjectStatus -): void { +export function saveProjectStatus(project: Project, status: MrAgentProjectStatus): void { const path = getStatusPath(project); mkdirSync(getProjectAgentStatusDir(project.name), { recursive: true }); writeFileSync(path, JSON.stringify(status, null, 2), 'utf-8'); diff --git a/src/advance/classic/utils/context-window.ts b/src/advance/classic/utils/context-window.ts index 99161df..c735e38 100755 --- a/src/advance/classic/utils/context-window.ts +++ b/src/advance/classic/utils/context-window.ts @@ -80,7 +80,7 @@ export async function summarizeThreadNotes( return { recentNotesText: '', summarizedCount: 0, summarized: false }; } - const normalizedNotes = notes.map((n) => ({ + const normalizedNotes = notes.map(n => ({ ...n, body: truncateBody(n.body, maxCharsPerItem), })); @@ -114,7 +114,11 @@ export async function summarizeThreadNotes( }; } -async function summarizeNotes(llmClient: LlmClient, notes: ThreadNote[], promptLoader?: PromptLoader): Promise { +async function summarizeNotes( + llmClient: LlmClient, + notes: ThreadNote[], + promptLoader?: PromptLoader +): Promise { if (notes.length === 0) return ''; const notesText = notes.map((n, idx) => formatNote(n, idx + 1)).join('\n\n'); diff --git a/src/advance/classic/utils/json-extraction.ts b/src/advance/classic/utils/json-extraction.ts index 6712f53..da38767 100755 --- a/src/advance/classic/utils/json-extraction.ts +++ b/src/advance/classic/utils/json-extraction.ts @@ -43,7 +43,11 @@ export function extractJsonText(text: string): string { return trimmed; } -function findBalancedRange(text: string, openChar: string, closeChar: string): { start: number; end: number } | null { +function findBalancedRange( + text: string, + openChar: string, + closeChar: string +): { start: number; end: number } | null { const start = text.indexOf(openChar); if (start === -1) return null; diff --git a/src/advance/classic/utils/memory-snapshot.ts b/src/advance/classic/utils/memory-snapshot.ts index 162c45f..6e31e93 100755 --- a/src/advance/classic/utils/memory-snapshot.ts +++ b/src/advance/classic/utils/memory-snapshot.ts @@ -1,4 +1,4 @@ -import { logger } from '../../../core/logger.js'; +import { logger } from '../../core/logger.js'; /** * 打印当前进程内存快照,使用同步日志确保崩溃前能刷出。 @@ -13,6 +13,8 @@ export function logMemorySnapshot(label: string): void { externalMB: Math.round(usage.external / 1024 / 1024), }; // 使用 console.log 同步输出,避免 pino 异步缓冲在 OOM 前丢失 - console.log(`[memory-snapshot] ${label} rss=${snapshot.rssMB}MB heapUsed=${snapshot.heapUsedMB}MB heapTotal=${snapshot.heapTotalMB}MB external=${snapshot.externalMB}MB`); + console.log( + `[memory-snapshot] ${label} rss=${snapshot.rssMB}MB heapUsed=${snapshot.heapUsedMB}MB heapTotal=${snapshot.heapTotalMB}MB external=${snapshot.externalMB}MB` + ); logger.debug(snapshot, '内存快照'); } diff --git a/src/advance/classic/worktree/file-overview-builder.ts b/src/advance/classic/worktree/file-overview-builder.ts index dc2ad7e..b6aad7b 100755 --- a/src/advance/classic/worktree/file-overview-builder.ts +++ b/src/advance/classic/worktree/file-overview-builder.ts @@ -31,7 +31,7 @@ export interface FileOverview { /** * 符号识别规则,按语言扩展 */ -const SYMBOL_PATTERNS: Array<{ kind: SymbolEntry['kind']; regex: RegExp } > = [ +const SYMBOL_PATTERNS: Array<{ kind: SymbolEntry['kind']; regex: RegExp }> = [ { kind: 'function', regex: /^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/ }, { kind: 'class', regex: /^(?:export\s+)?class\s+([A-Za-z_$][\w$]*)/ }, { kind: 'interface', regex: /^(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)/ }, diff --git a/src/advance/classic/worktree/worktree-manager.ts b/src/advance/classic/worktree/worktree-manager.ts index ab870d2..3d1bcf8 100755 --- a/src/advance/classic/worktree/worktree-manager.ts +++ b/src/advance/classic/worktree/worktree-manager.ts @@ -13,7 +13,7 @@ import { dirname, join, resolve } from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { createInterface } from 'node:readline'; -import { logger } from '../../../core/logger.js'; +import { logger } from '../../core/logger.js'; import { readRange } from './streaming-file-reader.js'; import { buildFileOverview, type FileOverview } from './file-overview-builder.js'; import { buildFocusedContextStreamed } from '../fix/focused-context-streamer.js'; diff --git a/src/advance/cli.ts b/src/advance/cli.ts index 8b2208a..e6f8e91 100755 --- a/src/advance/cli.ts +++ b/src/advance/cli.ts @@ -48,7 +48,10 @@ export async function main(): Promise { const archiveRoot = parseFlag(args, '--archive-root'); const { store, registry } = getDeps(); try { - const project = registry.register(resolve(rootPath), archiveRoot ? resolve(archiveRoot) : undefined); + const project = registry.register( + resolve(rootPath), + archiveRoot ? resolve(archiveRoot) : undefined + ); console.log(`已注册项目: ${project.name} (${project.id})`); if (project.archiveRoot) { console.log(`归档位置: ${project.archiveRoot}`); @@ -104,6 +107,7 @@ export async function main(): Promise { headers: persisted.headers, scanCron: persisted.scanCron, llmRequestsPerMinute: persisted.llmRequestsPerMinute, + agents: persisted.agents, }); daemon.start(); console.log('守护进程已启动'); @@ -150,7 +154,7 @@ export async function main(): Promise { const projects = registry.list(); console.log(`已注册项目数: ${projects.length}`); for (const p of projects) { - const pending = store.listPendingEvents(1000).filter((e) => e.projectId === p.id).length; + const pending = store.listPendingEvents(1000).filter(e => e.projectId === p.id).length; console.log(` ${p.name}: 待处理事件 ${pending}`); } } finally { @@ -201,7 +205,7 @@ export async function main(): Promise { process.exit(1); } const executor = new UndoExecutor({ store }); - const result = await executor.undo(actionId); + const result = await executor.undo(actionId, projectId); console.log(result.message); if (!result.success) process.exit(1); } finally { @@ -211,6 +215,8 @@ export async function main(): Promise { } console.log(`未知命令: ${command}`); - console.log('用法: codekeeper-advance [register|unregister|list|start|process|status|history|undo]'); + console.log( + '用法: codekeeper-advance [register|unregister|list|start|process|status|history|undo]' + ); process.exit(1); } diff --git a/src/advance/codekeeper/context-generator.ts b/src/advance/codekeeper/context-generator.ts index c527762..b43f497 100755 --- a/src/advance/codekeeper/context-generator.ts +++ b/src/advance/codekeeper/context-generator.ts @@ -71,7 +71,9 @@ export function generateContext(options: ContextGeneratorOptions): void { ? relative(options.archiveRoot, item.archivePath).replace(/\\/g, '/') : relative(options.projectRoot, item.filePath).replace(/\\/g, '/'); const statusBadge = item.status ? ` (${statusLabel(item.status)})` : ''; - lines.push(`- **${item.docType}** [${linkPath}](${encodePath(linkPath)})${statusBadge} — ${item.summary}`); + lines.push( + `- **${item.docType}** [${linkPath}](${encodePath(linkPath)})${statusBadge} — ${item.summary}` + ); if (item.tags.length > 0) { lines.push(` - 标签:${item.tags.join(', ')}`); } @@ -93,7 +95,10 @@ export function generateContext(options: ContextGeneratorOptions): void { } function anchor(text: string): string { - return text.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9一-龥-]/g, ''); + return text + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9一-龥-]/g, ''); } function encodePath(filePath: string): string { diff --git a/src/advance/codekeeper/status-updater.ts b/src/advance/codekeeper/status-updater.ts index 3ce7726..b8a07da 100755 --- a/src/advance/codekeeper/status-updater.ts +++ b/src/advance/codekeeper/status-updater.ts @@ -35,7 +35,8 @@ export function buildProjectStatus(params: { organizedCount: number; flaggedCount: number; }): ProjectStatus { - const total = params.pendingCount + params.archivedCount + params.ignoredCount + params.orphanedCount; + const total = + params.pendingCount + params.archivedCount + params.ignoredCount + params.orphanedCount; const resolved = params.archivedCount + params.ignoredCount + params.orphanedCount; const healthScore = total === 0 ? 1 : Math.round((resolved / total) * 100) / 100; return { diff --git a/src/advance/config/daemon-config.ts b/src/advance/config/daemon-config.ts index 84efadc..34eca02 100755 --- a/src/advance/config/daemon-config.ts +++ b/src/advance/config/daemon-config.ts @@ -1,6 +1,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; +import { agentSpecSchema } from '../agents/registry.js'; /** * EverOS 多模态模型覆盖配置。 @@ -35,6 +36,8 @@ export interface DaemonPersistedConfig { rerankModel?: string; /** EverOS 独立配置;未配置时继承 Agent 通用配置 */ everos?: EverOSConfig; + /** 外部 Agent 注册表(管线 agent.* 节点引用),结构见 agents/registry.ts */ + agents?: import('../agents/registry.js').AgentSpec[]; } const CONFIG_DIR = join(homedir(), '.codekeeper-advance'); @@ -46,7 +49,18 @@ export function loadDaemonConfig(): DaemonPersistedConfig { } try { const raw = readFileSync(CONFIG_PATH, 'utf-8'); - return JSON.parse(raw) as DaemonPersistedConfig; + const config = JSON.parse(raw) as DaemonPersistedConfig; + // agents 逐条校验:坏 spec 在启动期剔除,而不是等到节点执行时才炸 + if (Array.isArray(config.agents)) { + config.agents = config.agents.filter(spec => { + const result = agentSpecSchema.safeParse(spec); + if (!result.success) { + console.warn('[daemon-config] 忽略非法的外部 Agent 配置项:', spec?.id ?? '(无 id)'); + } + return result.success; + }); + } + return config; } catch { return {}; } @@ -64,14 +78,14 @@ export function saveDaemonConfig(config: DaemonPersistedConfig): void { merged.everos = { ...existing.everos, ...config.everos }; } // 删除 undefined 字段 - (Object.keys(merged) as Array).forEach((key) => { + (Object.keys(merged) as Array).forEach(key => { if (merged[key] === undefined) { delete merged[key]; } }); const everosConfig = merged.everos; if (everosConfig) { - (Object.keys(everosConfig) as Array).forEach((key) => { + (Object.keys(everosConfig) as Array).forEach(key => { if (everosConfig[key] === undefined) { delete everosConfig[key]; } diff --git a/src/core/logger.ts b/src/advance/core/logger.ts similarity index 100% rename from src/core/logger.ts rename to src/advance/core/logger.ts diff --git a/src/core/platform.ts b/src/advance/core/platform.ts similarity index 100% rename from src/core/platform.ts rename to src/advance/core/platform.ts diff --git a/src/advance/daemon.ts b/src/advance/daemon.ts index bcdda51..09c8535 100755 --- a/src/advance/daemon.ts +++ b/src/advance/daemon.ts @@ -1,199 +1,235 @@ -import { schedule } from 'node-cron'; -import type { ProjectRegistry } from './project-registry'; -import { FileWatcher } from './file-watcher'; -import type { MetadataStore } from './store/metadata-store'; -import type { Project, WatchedEvent } from './types'; -import { getArchiveRoot } from './types'; -import { loadProjectConfig } from './config/project-config'; -import { saveDaemonConfig } from './config/daemon-config'; -import { IpcServer } from './ipc/server'; -import { getIpcSocketPath } from './ipc/paths'; -import { handlers, type HandlerContext } from './ipc/handlers'; -import { logger } from '../core/logger'; -import { LlmClient } from './llm/client'; -import { RoleServiceRegistry } from './classic/role-service-registry.js'; -import { ROLES } from './types.js'; -import { ScanService } from './scan/scan-service.js'; -import { GitLabProvider } from './classic/provider/gitlab-provider.js'; -import { EverOSService } from './classic/memory/everos-service.js'; -import { EverOSMcpServer } from './classic/memory/everos-mcp-server.js'; -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 { schedule } from 'node-cron'; +import type { ProjectRegistry } from './project-registry'; +import { FileWatcher } from './file-watcher'; +import type { MetadataStore } from './store/metadata-store'; +import type { Project, WatchedEvent } from './types'; +import { getArchiveRoot } from './types'; +import { loadProjectConfig } from './config/project-config'; +import { saveDaemonConfig } from './config/daemon-config'; +import { IpcServer } from './ipc/server'; +import { getIpcSocketPath } from './ipc/paths'; +import { handlers, type HandlerContext } from './ipc/handlers'; +import { logger } from './core/logger'; +import { LlmClient } from './llm/client'; +import { PipelineScheduler } from './pipeline/pipeline-scheduler.js'; +import { McpFacadeServer } from './agents/mcp-facade.js'; +import { ROLES } from './types.js'; +import { ScanService } from './scan/scan-service.js'; +import { GitLabProvider } from './classic/provider/gitlab-provider.js'; +import { EverOSService } from './classic/memory/everos-service.js'; +import { EverOSMcpServer } from './classic/memory/everos-mcp-server.js'; +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 { - CodeGraphServiceStatus, - EverosStatus, -} from '../electron/shared/service-status.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; - store: MetadataStore; - /** 数据库文件路径,用于启动扫描 worker */ - dbPath: string; - /** LLM API Key */ - apiKey?: string; - /** 自定义 LLM API Base URL */ - apiUrl?: string; - /** LLM 服务提供商 */ - provider?: 'anthropic' | 'openai'; - /** LLM 模型名称 */ - model?: string; - /** 自定义 LLM 请求头(JSON 对象) */ - headers?: Record; - /** 全量扫描的 cron 表达式,默认每 5 分钟 */ - scanCron?: string; - /** 每次扫描最多处理事件数 */ - maxEventsPerScan?: number; - /** 每分钟 LLM 请求数限制,默认 10 */ - llmRequestsPerMinute?: number; - /** 本地 Embedding 模型名;默认使用 DEFAULT_EMBEDDING_MODEL */ - embeddingModel?: string; - /** 本地 Rerank 模型名;默认使用 DEFAULT_RERANK_MODEL */ - rerankModel?: string; +import { CodeGraphService, type CodeGraphServiceController } from './archiver/codegraph-service.js'; + +export interface DaemonOptions { + registry: ProjectRegistry; + store: MetadataStore; + /** 数据库文件路径,用于启动扫描 worker */ + dbPath: string; + /** LLM API Key */ + apiKey?: string; + /** 自定义 LLM API Base URL */ + apiUrl?: string; + /** LLM 服务提供商 */ + provider?: 'anthropic' | 'openai'; + /** LLM 模型名称 */ + model?: string; + /** 自定义 LLM 请求头(JSON 对象) */ + headers?: Record; + /** 全量扫描的 cron 表达式,默认每 5 分钟 */ + scanCron?: string; + /** 每次扫描最多处理事件数 */ + maxEventsPerScan?: number; + /** 每分钟 LLM 请求数限制,默认 10 */ + llmRequestsPerMinute?: number; + /** 本地 Embedding 模型名;默认使用 DEFAULT_EMBEDDING_MODEL */ + embeddingModel?: string; + /** 本地 Rerank 模型名;默认使用 DEFAULT_RERANK_MODEL */ + rerankModel?: string; /** EverOS 独立配置;未设置时继承 Agent 通用配置 */ everos?: import('./config/daemon-config.js').EverOSConfig; + /** 外部 Agent 注册表(管线 agent.* 节点引用) */ + agents?: import('./agents/registry.js').AgentSpec[]; /** 测试或嵌入场景可替换 CodeGraph 服务实现 */ codeGraphService?: CodeGraphServiceController; } - -export class Daemon { - private watchers = new Map(); - private scanJob: ReturnType | null = null; - private running = false; - private ipcServer: IpcServer | null = null; - private watcherInitImmediate: NodeJS.Immediate | null = null; - private watcherTimeout: NodeJS.Timeout | null = null; - private handlerContext: HandlerContext; - private serviceRegistry: RoleServiceRegistry; - private scanService: ScanService; - private everosService: EverOSService | null = null; - private everosMcpServer: EverOSMcpServer | null = null; - private everosMcpUrl: string | null = null; - private everosStarting = false; - private everosState: EverosStatus['state'] = 'idle'; - private everosHttpUrl: string | null = null; - private everosError: string | null = null; + +export class Daemon { + private watchers = new Map(); + private scanJob: ReturnType | null = null; + private running = false; + private ipcServer: IpcServer | null = null; + private watcherInitImmediate: NodeJS.Immediate | null = null; + private watcherTimeout: NodeJS.Timeout | null = null; + private handlerContext: HandlerContext; + private serviceRegistry: PipelineScheduler; + private scanService: ScanService; + private everosService: EverOSService | null = null; + private everosMcpServer: EverOSMcpServer | null = null; + private everosMcpUrl: string | null = null; + private mcpFacade: McpFacadeServer | null = null; + private mcpFacadeUrl: string | null = null; + private everosStarting = false; + private everosState: EverosStatus['state'] = 'idle'; + private everosHttpUrl: string | null = null; + private everosError: string | null = null; private localModelManager: LocalModelServiceManager; private codeGraphService: CodeGraphServiceController; private memoryWriteQueue: MemoryWriteQueue; - private memoryWriteRetryService: MemoryWriteRetryService; - private embeddingDim: number | null = null; - private remoteModelChecker = new RemoteModelChecker(); - private remoteModelStatus: RemoteModelStatus = { - llm: { state: 'unconfigured', modelLabel: '未配置', fullModel: '', baseUrl: null, error: null, lastCheckedAt: 0 }, - multimodal: { state: 'unconfigured', modelLabel: '未配置', fullModel: '', baseUrl: null, error: null, lastCheckedAt: 0 }, - }; - private remoteModelCheckTimer: NodeJS.Timeout | null = null; - - constructor(private options: DaemonOptions) { - // 初始化角色服务注册表,注册所有支持的角色 - this.serviceRegistry = new RoleServiceRegistry( - // 先创建占位 context,等 scanService 初始化后再补全 - {} as HandlerContext, - path.join(__dirname, 'classic', 'agent-entries', 'role-entry.js') - ); - for (const role of ROLES) { - this.serviceRegistry.register(role); - } - - this.scanService = new ScanService({ - store: options.store, - registry: options.registry, - dbPath: options.dbPath, - getDaemonConfig: () => ({ - apiKey: this.options.apiKey ?? '', - apiUrl: this.options.apiUrl ?? '', - provider: this.options.provider ?? 'anthropic', - model: this.options.model ?? '', - headers: this.options.headers ?? {}, - llmRequestsPerMinute: this.options.llmRequestsPerMinute ?? 10, - }), - maxEventsPerScan: this.options.maxEventsPerScan, - }); - + private memoryWriteRetryService: MemoryWriteRetryService; + private embeddingDim: number | null = null; + private remoteModelChecker = new RemoteModelChecker(); + private remoteModelStatus: RemoteModelStatus = { + llm: { + state: 'unconfigured', + modelLabel: '未配置', + fullModel: '', + baseUrl: null, + error: null, + lastCheckedAt: 0, + }, + multimodal: { + state: 'unconfigured', + modelLabel: '未配置', + fullModel: '', + baseUrl: null, + error: null, + lastCheckedAt: 0, + }, + }; + private remoteModelCheckTimer: NodeJS.Timeout | null = null; + + constructor(private options: DaemonOptions) { + // 初始化管线调度器(兼容旧 RoleServiceRegistry 接口),注册所有支持的角色 + this.serviceRegistry = new PipelineScheduler( + // 先创建占位 context,等 scanService 初始化后再补全 + {} as HandlerContext, + path.join(__dirname, 'classic', 'agent-entries', 'role-entry.js') + ); + for (const role of ROLES) { + this.serviceRegistry.register(role); + } + + this.scanService = new ScanService({ + store: options.store, + registry: options.registry, + dbPath: options.dbPath, + getDaemonConfig: () => ({ + apiKey: this.options.apiKey ?? '', + apiUrl: this.options.apiUrl ?? '', + provider: this.options.provider ?? 'anthropic', + model: this.options.model ?? '', + headers: this.options.headers ?? {}, + llmRequestsPerMinute: this.options.llmRequestsPerMinute ?? 10, + agents: this.options.agents ?? [], + }), + maxEventsPerScan: this.options.maxEventsPerScan, + }); + 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({ - queue: this.memoryWriteQueue, - getEverosUrl: () => this.everosHttpUrl ?? undefined, - }); - - this.handlerContext = { - store: options.store, - registry: options.registry, - serviceRegistry: this.serviceRegistry, - dbPath: options.dbPath, - scanService: this.scanService, + + this.memoryWriteQueue = new MemoryWriteQueue({ store: options.store }); + this.memoryWriteRetryService = new MemoryWriteRetryService({ + queue: this.memoryWriteQueue, + getEverosUrl: () => this.everosHttpUrl ?? undefined, + }); + + this.handlerContext = { + store: options.store, + registry: options.registry, + serviceRegistry: this.serviceRegistry, + dbPath: options.dbPath, + scanService: this.scanService, localModelManager: this.localModelManager, codeGraphService: this.codeGraphService, remoteModelChecker: this.remoteModelChecker, - getRemoteModelStatus: () => this.remoteModelStatus, - getProvider: (project) => { - if (project.gitlab) { - return new GitLabProvider(project.gitlab); - } - return null; - }, - getClient: () => - this.options.apiKey - ? new LlmClient({ - apiKey: this.options.apiKey, - baseURL: this.options.apiUrl, - provider: this.options.provider, - model: this.options.model, - headers: this.options.headers, - minRequestInterval: this.llmRequestInterval(), - }) - : null, - updateDaemonConfig: (config) => this.updateConfig(config), - getDaemonConfig: () => ({ - apiKey: this.options.apiKey ?? '', - apiUrl: this.options.apiUrl ?? '', - provider: this.options.provider ?? 'anthropic', - model: this.options.model ?? '', - headers: this.options.headers ? JSON.stringify(this.options.headers) : '', - scanCron: this.options.scanCron ?? '*/5 * * * *', - llmRequestsPerMinute: this.options.llmRequestsPerMinute ?? 10, - embeddingModel: this.options.embeddingModel ?? DEFAULT_EMBEDDING_MODEL, - rerankModel: this.options.rerankModel ?? DEFAULT_RERANK_MODEL, - everos: this.options.everos ? JSON.stringify(this.options.everos) : '', - }), - isDaemonRunning: () => this.running, + getRemoteModelStatus: () => this.remoteModelStatus, + getProvider: project => { + if (project.gitlab) { + return new GitLabProvider(project.gitlab); + } + return null; + }, + getClient: () => + this.options.apiKey + ? new LlmClient({ + apiKey: this.options.apiKey, + baseURL: this.options.apiUrl, + provider: this.options.provider, + model: this.options.model, + headers: this.options.headers, + minRequestInterval: this.llmRequestInterval(), + }) + : null, + updateDaemonConfig: config => this.updateConfig(config), + getDaemonConfig: () => ({ + apiKey: this.options.apiKey ?? '', + apiUrl: this.options.apiUrl ?? '', + provider: this.options.provider ?? 'anthropic', + model: this.options.model ?? '', + headers: this.options.headers ? JSON.stringify(this.options.headers) : '', + scanCron: this.options.scanCron ?? '*/5 * * * *', + llmRequestsPerMinute: this.options.llmRequestsPerMinute ?? 10, + embeddingModel: this.options.embeddingModel ?? DEFAULT_EMBEDDING_MODEL, + rerankModel: this.options.rerankModel ?? DEFAULT_RERANK_MODEL, + everos: this.options.everos ? JSON.stringify(this.options.everos) : '', + agents: this.options.agents ?? [], + }), + isDaemonRunning: () => this.running, getEverosStatus: () => this.getEverosStatus(), getCodeGraphStatus: () => this.getCodeGraphStatus(), - watchProject: (project) => this.watchProject(project), - unwatchProject: (projectId) => this.unwatchProject(projectId), - }; - - // 将完整的 handlerContext 回设到 serviceRegistry - this.serviceRegistry.context = this.handlerContext; - } - - async start(): Promise { - if (this.running) return; - this.running = true; - - this.ipcServer = new IpcServer({ - socketPath: getIpcSocketPath(), - handler: (method, params) => this.handleIpc(method, params), - }); + watchProject: project => this.watchProject(project), + unwatchProject: projectId => this.unwatchProject(projectId), + getMcpFacadeUrl: () => this.mcpFacadeUrl, + }; + + // 将完整的 handlerContext 回设到 serviceRegistry + this.serviceRegistry.context = this.handlerContext; + } + + async start(): Promise { + if (this.running) return; + this.running = true; + + this.ipcServer = new IpcServer({ + socketPath: getIpcSocketPath(), + handler: (method, params) => this.handleIpc(method, params), + }); await this.ipcServer.start(); + // MCP 门面独立于 EverOS 启动:pipeline_list_runs/submit 不依赖记忆服务, + // knowledge_recall 在 EverOS 未就绪时自行降级 + try { + this.mcpFacade = new McpFacadeServer({ + scheduler: this.serviceRegistry, + getEverosUrl: () => this.everosHttpUrl, + }); + this.mcpFacadeUrl = await this.mcpFacade.start(); + logger.info({ mcpFacadeUrl: this.mcpFacadeUrl }, 'MCP 门面已启动(外部 Agent 接入)'); + } catch (err) { + logger.warn({ err }, 'MCP 门面启动失败(不影响其余功能)'); + this.mcpFacade = null; + this.mcpFacadeUrl = null; + } + try { const codeGraphUrl = await this.codeGraphService.start(); this.serviceRegistry.setCodeGraphUrl(codeGraphUrl); @@ -202,506 +238,525 @@ export class Daemon { } // 启动本地 Embedding/Rerank 模型服务(后台进行,不阻塞 IPC),就绪后自动启动 EverOS - this.localModelManager - .start() - .then(async () => { - if (!this.running) return; - const status = this.localModelManager.getStatus(); - const ready = status.embedding.state === 'running' && status.rerank.state === 'running'; - if (ready) { - // 根据实际选用的 HuggingFace embedding 模型解析输出维度,再启动 EverOS - const embedModel = this.options.embeddingModel ?? DEFAULT_EMBEDDING_MODEL; - this.embeddingDim = await fetchEmbeddingModelDimension(embedModel).catch(() => null); - if (this.embeddingDim) { - logger.info({ model: embedModel, dim: this.embeddingDim }, '已解析 embedding 模型维度'); - } else { - logger.warn({ model: embedModel }, '无法从 HuggingFace 解析 embedding 维度,使用本地默认值'); - } - logger.info('本地模型服务已就绪,尝试启动/重启 EverOS'); - await this.startEverOS(); - } else { - logger.warn( - { status }, - '本地模型服务未完全就绪,EverOS 将不会自动启动,Agent 子进程会等待就绪' - ); - } - }) - .catch((err) => { - const message = err instanceof Error ? err.message : String(err); - logger.warn({ err }, `本地模型服务未启动: ${message}`); - logger.warn('本地模型服务未就绪,EverOS 将不会自动启动,Agent 子进程会等待就绪'); - }); - - // 初始检测一次远端模型连通性,并启动 30s 轮询 - this.checkRemoteModels().catch((err) => logger.warn({ err }, '初始远端模型检测失败')); - this.startRemoteModelChecks(); - this.memoryWriteRetryService.start(); - - // IPC server 启动后,把文件监控等非关键初始化推迟到下一个事件循环, - // 让 UI 在 App 刚打开时能立即响应 IPC 请求,避免按钮点击延迟。 - // MR Agent 调度服务(ClassicService)不随 daemon 启动,由 UI 的“启动服务”按钮控制。 - this.watcherInitImmediate = setImmediate(() => { - // 进一步延迟 watcher 启动 3 秒,避免 App 启动瞬间的 IO 峰值阻塞主进程事件循环 - this.watcherTimeout = setTimeout(() => { - const projects = this.options.registry.list(); - // 错开每个项目 watcher 的启动时间,避免多个 chokidar 同时扫描目录阻塞事件循环 - projects.forEach((project, index) => { - setTimeout(() => this.watchProject(project), index * 500); - }); - }, 3000); - }); - - const cron = this.options.scanCron ?? '*/5 * * * *'; - this.scanJob = schedule(cron, () => this.scanService.scanAllProjects()); - } - - async stop(): Promise { - if (!this.running) return; - this.running = false; - if (this.watcherInitImmediate) { - clearImmediate(this.watcherInitImmediate); - this.watcherInitImmediate = null; - } - if (this.watcherTimeout) { - clearTimeout(this.watcherTimeout); - this.watcherTimeout = null; - } - // 停止所有角色服务 - for (const role of ROLES) { - await this.serviceRegistry.stop(role); + this.localModelManager + .start() + .then(async () => { + if (!this.running) return; + const status = this.localModelManager.getStatus(); + const ready = status.embedding.state === 'running' && status.rerank.state === 'running'; + if (ready) { + // 根据实际选用的 HuggingFace embedding 模型解析输出维度,再启动 EverOS + const embedModel = this.options.embeddingModel ?? DEFAULT_EMBEDDING_MODEL; + this.embeddingDim = await fetchEmbeddingModelDimension(embedModel).catch(() => null); + if (this.embeddingDim) { + logger.info({ model: embedModel, dim: this.embeddingDim }, '已解析 embedding 模型维度'); + } else { + logger.warn( + { model: embedModel }, + '无法从 HuggingFace 解析 embedding 维度,使用本地默认值' + ); + } + logger.info('本地模型服务已就绪,尝试启动/重启 EverOS'); + await this.startEverOS(); + } else { + logger.warn( + { status }, + '本地模型服务未完全就绪,EverOS 将不会自动启动,Agent 子进程会等待就绪' + ); + } + }) + .catch(err => { + const message = err instanceof Error ? err.message : String(err); + logger.warn({ err }, `本地模型服务未启动: ${message}`); + logger.warn('本地模型服务未就绪,EverOS 将不会自动启动,Agent 子进程会等待就绪'); + }); + + // 初始检测一次远端模型连通性,并启动 30s 轮询 + this.checkRemoteModels().catch(err => logger.warn({ err }, '初始远端模型检测失败')); + this.startRemoteModelChecks(); + this.memoryWriteRetryService.start(); + + // IPC server 启动后,把文件监控等非关键初始化推迟到下一个事件循环, + // 让 UI 在 App 刚打开时能立即响应 IPC 请求,避免按钮点击延迟。 + // MR Agent 调度服务(ClassicService)不随 daemon 启动,由 UI 的“启动服务”按钮控制。 + this.watcherInitImmediate = setImmediate(() => { + // 进一步延迟 watcher 启动 3 秒,避免 App 启动瞬间的 IO 峰值阻塞主进程事件循环 + this.watcherTimeout = setTimeout(() => { + const projects = this.options.registry.list(); + // 错开每个项目 watcher 的启动时间,避免多个 chokidar 同时扫描目录阻塞事件循环 + projects.forEach((project, index) => { + setTimeout(() => this.watchProject(project), index * 500); + }); + }, 3000); + }); + + const cron = this.options.scanCron ?? '*/5 * * * *'; + this.scanJob = schedule(cron, () => this.scanService.scanAllProjects()); + } + + async stop(): Promise { + if (!this.running) return; + this.running = false; + if (this.watcherInitImmediate) { + clearImmediate(this.watcherInitImmediate); + this.watcherInitImmediate = null; + } + if (this.watcherTimeout) { + clearTimeout(this.watcherTimeout); + this.watcherTimeout = null; } + // 停止 MCP 门面与所有管线触发器、角色节点子进程 + await this.mcpFacade?.stop(); + this.mcpFacade = null; + this.mcpFacadeUrl = null; + await this.serviceRegistry.stopAll(); await this.codeGraphService.stop(); this.scanService.stop(); - this.scanJob?.stop(); - this.scanJob = null; - this.localModelManager.stop(); - if (this.remoteModelCheckTimer) { - clearInterval(this.remoteModelCheckTimer); - this.remoteModelCheckTimer = null; - } - this.memoryWriteRetryService.stop(); - await this.everosMcpServer?.stop(); - this.everosService?.stop(); - this.everosMcpServer = null; - this.everosService = null; - this.everosMcpUrl = null; - this.everosHttpUrl = null; - this.everosState = 'idle'; - this.everosError = null; - for (const watchers of this.watchers.values()) { - for (const watcher of watchers) { - watcher.stop(); - } - } - this.watchers.clear(); - await this.ipcServer?.stop(); - this.ipcServer = null; - } - - isRunning(): boolean { - return this.running; - } - + this.scanJob?.stop(); + this.scanJob = null; + this.localModelManager.stop(); + if (this.remoteModelCheckTimer) { + clearInterval(this.remoteModelCheckTimer); + this.remoteModelCheckTimer = null; + } + this.memoryWriteRetryService.stop(); + await this.everosMcpServer?.stop(); + this.everosService?.stop(); + this.everosMcpServer = null; + this.everosService = null; + this.everosMcpUrl = null; + this.everosHttpUrl = null; + this.everosState = 'idle'; + this.everosError = null; + for (const watchers of this.watchers.values()) { + for (const watcher of watchers) { + watcher.stop(); + } + } + this.watchers.clear(); + await this.ipcServer?.stop(); + this.ipcServer = null; + } + + isRunning(): boolean { + return this.running; + } + getEverosStatus(): EverosStatus { - return { - state: this.everosState, - url: this.everosHttpUrl, - error: this.everosError, - }; + 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([ - this.remoteModelChecker.checkLlm({ - apiKey: this.options.apiKey, - apiUrl: this.options.apiUrl, - provider: this.options.provider, - model: this.options.model, - headers: this.options.headers, - }), - this.remoteModelChecker.checkMultimodal(this.options.everos ?? {}), - ]); - this.remoteModelStatus = { llm, multimodal }; - } - - private startRemoteModelChecks(): void { - if (this.remoteModelCheckTimer) return; - this.remoteModelCheckTimer = setInterval(() => { - if (!this.running) return; - this.checkRemoteModels().catch((err) => { - logger.warn({ err }, '远端模型状态检测失败'); - }); - }, 30000); - } - - /** - * 启动 EverOS + MCP Server。 - * - * 可被 start() 和 updateConfig() 调用;已启动或正在启动时不会重复执行。 - */ - private async startEverOS(): Promise { - if (this.everosStarting) return; - this.everosStarting = true; - this.everosState = 'starting'; - this.everosError = null; - - // EverOS 必须依赖远端 LLM 配置才能启动,且要求 OpenAI 兼容协议 - const effectiveLlmUrl = (this.options.apiUrl ? cleanBaseUrl(this.options.apiUrl) : undefined) || this.getDefaultLlmBaseUrl(this.options.provider); - if (!this.options.apiKey || !effectiveLlmUrl) { - const providerHint = this.options.provider === 'anthropic' - ? '当前 Provider 为 Anthropic,EverOS LLM 需要 OpenAI 兼容的 Base URL,请在设置页填写代理地址或切换到 OpenAI 兼容 Provider。' - : '请先在设置页填写 API Key 和 Base URL。'; - const message = `EverOS 需要远端 LLM 配置:${providerHint}`; - logger.warn(message); - this.everosState = 'error'; - this.everosError = message; - this.everosStarting = false; - return; - } - - try { - if (this.everosService && this.everosMcpUrl) { - this.everosState = 'running'; - return; - } - - // 清理之前失败或未完成的实例 - if (this.everosService) { - this.everosService.stop(); - this.everosService = null; - } - await this.everosMcpServer?.stop(); - this.everosMcpServer = null; - this.everosMcpUrl = null; - this.everosHttpUrl = null; - - const submodulePath = join(__dirname, '..', '..', 'vendor', 'everos'); - this.everosService = new EverOSService({ - submodulePath, - env: this.buildEverOSEnv(), - }); - const everosUrl = await this.everosService.start(); - this.handlerContext.everosUrl = everosUrl; - this.everosHttpUrl = everosUrl; - this.everosMcpServer = new EverOSMcpServer({ - everosUrl, - queue: this.memoryWriteQueue, - onMemoryOwners: (projectId, owners) => { - for (const { ownerId, ownerType, displayName } of owners) { - this.options.store.recordMemoryOwner(projectId, ownerId, ownerType, displayName); - } - }, - }); - this.everosMcpUrl = await this.everosMcpServer.start(); - this.serviceRegistry.setMemoryMcpUrl(this.everosMcpUrl); - this.everosState = 'running'; - this.everosError = null; - logger.info({ everosUrl, mcpUrl: this.everosMcpUrl }, 'EverOS 记忆基础设施已启动'); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger.warn({ err }, `EverOS 未启动: ${message}`); - this.everosState = 'error'; - this.everosError = message; - // 记忆基础设施失败不应阻塞 daemon 其余功能 - this.everosService = null; - this.everosMcpServer = null; - this.everosMcpUrl = null; - this.everosHttpUrl = null; - } finally { - this.everosStarting = false; - } - } - - updateConfig(config: { - apiKey?: string; - apiUrl?: string; - provider?: 'anthropic' | 'openai'; - model?: string; - headers?: Record; - scanCron?: string; - llmRequestsPerMinute?: number; - embeddingModel?: string; - rerankModel?: string; - everos?: import('./config/daemon-config.js').EverOSConfig; - }): void { - const persisted: import('./config/daemon-config.js').DaemonPersistedConfig = {}; - - const isChanged = (oldValue: T, newValue: T | undefined): boolean => - newValue !== undefined && newValue !== oldValue; - - const memoryConfigChanged = - isChanged(this.options.apiKey, config.apiKey) || - isChanged(this.options.apiUrl, config.apiUrl) || - isChanged(this.options.provider, config.provider) || - isChanged(this.options.model, config.model) || - isChanged(this.options.embeddingModel, config.embeddingModel) || - isChanged(this.options.rerankModel, config.rerankModel) || - (config.everos !== undefined && - JSON.stringify(this.options.everos) !== JSON.stringify(config.everos)); - - if (config.apiKey !== undefined) { - this.options.apiKey = config.apiKey; - persisted.apiKey = config.apiKey; - } - if (config.apiUrl !== undefined) { - this.options.apiUrl = config.apiUrl; - persisted.apiUrl = config.apiUrl; - } - if (config.provider !== undefined) { - this.options.provider = config.provider; - persisted.provider = config.provider; - } - if (config.model !== undefined) { - this.options.model = config.model; - persisted.model = config.model; - } - if (config.headers !== undefined) { - this.options.headers = config.headers; - persisted.headers = config.headers; - } - if (config.scanCron !== undefined) { - this.options.scanCron = config.scanCron; - this.scanJob?.stop(); - this.scanJob = schedule(config.scanCron, () => this.scanService.scanAllProjects()); - persisted.scanCron = config.scanCron; - } - if (config.llmRequestsPerMinute !== undefined) { - this.options.llmRequestsPerMinute = config.llmRequestsPerMinute; - persisted.llmRequestsPerMinute = config.llmRequestsPerMinute; - } - - const embeddingModelChanged = isChanged(this.options.embeddingModel, config.embeddingModel); - const rerankModelChanged = isChanged(this.options.rerankModel, config.rerankModel); - if (config.embeddingModel !== undefined) { - this.options.embeddingModel = config.embeddingModel; - persisted.embeddingModel = config.embeddingModel; - } - if (config.rerankModel !== undefined) { - this.options.rerankModel = config.rerankModel; - persisted.rerankModel = config.rerankModel; - } - if (config.everos !== undefined) { - this.options.everos = config.everos; - persisted.everos = config.everos; - } - - // 本地模型名变更时替换 manager 实例,使新模型在下次启动时生效 - if (embeddingModelChanged || rerankModelChanged) { - this.localModelManager.stop(); - this.localModelManager = new LocalModelServiceManager({ - embeddingModel: this.options.embeddingModel, - rerankModel: this.options.rerankModel, - }); - this.handlerContext.localModelManager = this.localModelManager; - } - - if (Object.keys(persisted).length > 0) { - saveDaemonConfig(persisted); - } - - // 记忆相关配置真正发生变化时:先停掉 EverOS,再启动本地模型服务,最后用新 env 重启 EverOS - if (this.running && memoryConfigChanged) { - this.stopEverOS().then(() => - this.localModelManager - .start() - .then(async () => { - const status = this.localModelManager.getStatus(); - const ready = status.embedding.state === 'running' && status.rerank.state === 'running'; - if (ready) { - await this.startEverOS(); - } else { - logger.warn({ status }, '配置更新后本地模型服务未完全就绪,EverOS 保持停止'); - } - }) - .catch((err) => { - const message = err instanceof Error ? err.message : String(err); - logger.warn({ err }, `配置更新后本地模型服务/EverOS 启动失败: ${message}`); - }) - ); - } - - // 远端模型配置变化后刷新连通性检测 - const remoteConfigChanged = - config.apiKey !== undefined || - config.apiUrl !== undefined || - config.provider !== undefined || - config.model !== undefined || - config.everos !== undefined; - if (this.running && remoteConfigChanged) { - this.checkRemoteModels().catch((err) => - logger.warn({ err }, '配置更新后远端模型检测失败') - ); - } - } - - private async stopEverOS(): Promise { - if (this.everosStarting) { - // 正在启动时避免竞态,简单置标记让启动完成后自然结束 - this.everosStarting = false; - } - this.everosState = 'idle'; - this.everosError = null; - this.everosHttpUrl = null; - this.everosMcpUrl = null; - this.handlerContext.everosUrl = undefined; - await this.everosMcpServer?.stop(); - this.everosMcpServer = null; - this.everosService?.stop(); - this.everosService = null; - } - - private buildEverOSEnv(): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...process.env }; - const agent = { - apiKey: this.options.apiKey, - apiUrl: this.options.apiUrl, - model: this.options.model, - }; - const cfg = this.options.everos ?? {}; - - // Embedding / Rerank:使用本地模型服务,未就绪时回退到 Agent 通用配置 - // 本地 infinity_emb 使用 --url-prefix /v1,所以传给 EverOS 的 base_url 需要带 /v1 后缀 - const embedKey = agent.apiKey ?? 'local'; - const rawEmbedUrl = this.localModelManager.getEmbeddingUrl() ?? agent.apiUrl; - const embedUrl = rawEmbedUrl ? this.ensureLocalModelBaseUrl(rawEmbedUrl) : undefined; - const embedModel = this.options.embeddingModel ?? DEFAULT_EMBEDDING_MODEL; - if (embedKey) env.EVEROS_EMBEDDING__API_KEY = embedKey; - if (embedUrl) env.EVEROS_EMBEDDING__BASE_URL = embedUrl; - env.EVEROS_EMBEDDING__MODEL = embedModel; - env.EVEROS_EMBEDDING__DIM = String(this.embeddingDim ?? getEmbeddingModelDimension(embedModel)); - - const rerankKey = agent.apiKey ?? 'local'; - const rawRerankUrl = this.localModelManager.getRerankUrl() ?? agent.apiUrl; - const rerankUrl = rawRerankUrl ? this.ensureLocalModelBaseUrl(rawRerankUrl) : undefined; - const rerankModel = this.options.rerankModel ?? DEFAULT_RERANK_MODEL; - if (rerankKey) env.EVEROS_RERANK__API_KEY = rerankKey; - if (rerankUrl) env.EVEROS_RERANK__BASE_URL = rerankUrl; - env.EVEROS_RERANK__MODEL = rerankModel; - // 本地 rerank 使用 infinity_emb 的 OpenAI-compatible /v1/rerank,显式指定 vllm provider - // 避免 EverOS 按 host 推断成 deepinfra - if (this.localModelManager.getRerankUrl()) { - env.EVEROS_RERANK__PROVIDER = 'vllm'; - } - - // LLM:EverOS 的 LLM 客户端使用 OpenAI 兼容协议;优先使用用户填写的 Base URL, - // OpenAI 官方 provider 留空时补全默认值,Anthropic 原生 API 不兼容,不自动补全 - const effectiveLlmUrl = (agent.apiUrl ? cleanBaseUrl(agent.apiUrl) : undefined) || this.getDefaultLlmBaseUrl(this.options.provider); - if (agent.apiKey) env.EVEROS_LLM__API_KEY = agent.apiKey; - if (effectiveLlmUrl) env.EVEROS_LLM__BASE_URL = effectiveLlmUrl; - if (agent.model) env.EVEROS_LLM__MODEL = agent.model; - - // memorize 流水线可能调用 LLM,若 LLM 无响应会长期占用 per-session lock; - // 将 lock 超时从默认 360s 降到 120s,避免单个卡住的调用阻塞同一 MR session 的后续记忆写入 - env.EVEROS_MEMORIZE__SESSION_LOCK_TIMEOUT_SECONDS = '120'; - - logger.info( - { provider: this.options.provider, baseUrl: effectiveLlmUrl ?? 'unset', model: agent.model || 'default' }, - '构建 EverOS LLM 环境变量' - ); - - // Multimodal:仅使用显式配置,未配置时不回退到 Agent LLM - const mmKey = cfg.multimodalApiKey; - const mmUrl = cfg.multimodalBaseUrl; - const mmModel = cfg.multimodalModel; - if (mmKey) env.EVEROS_MULTIMODAL__API_KEY = mmKey; - if (mmUrl) env.EVEROS_MULTIMODAL__BASE_URL = mmUrl; - if (mmModel) env.EVEROS_MULTIMODAL__MODEL = mmModel; - - return env; - } - - private getDefaultLlmBaseUrl(provider?: string): string | undefined { - // EverOS 的 LLM 模块只支持 OpenAI 兼容协议;Anthropic 原生 /v1/messages 不兼容 - if (provider === 'openai') { - return 'https://api.openai.com/v1'; - } - return undefined; - } - - /** - * 确保本地模型服务的 base_url 带 /v1 前缀。 - * - * infinity_emb 启动时带 --url-prefix /v1,OpenAI SDK 会再拼 /embeddings 或 /rerank; - * 如果直接传裸地址,EverOS 会请求 /embeddings 而不是 /v1/embeddings,导致 404。 - */ - private ensureLocalModelBaseUrl(url: string): string { - const trimmed = url.replace(/\/+$/, ''); - if (trimmed.endsWith('/v1')) { - return trimmed; - } - return `${trimmed}/v1`; - } - - private llmRequestInterval(): number { - const rpm = this.options.llmRequestsPerMinute ?? 10; - if (rpm <= 0) return 6000; - return Math.ceil(60000 / rpm); - } - - private async handleIpc(method: string, params: unknown): Promise { - const handler = handlers[method]; - if (!handler) throw new Error(`未知 method: ${method}`); - return handler(this.handlerContext, params); - } - - watchProject(project: Project): void { - if (this.watchers.has(project.id)) return; - - const config = loadProjectConfig(project.rootPath, project.archiveRoot); - const watchers: FileWatcher[] = []; - - // 监听原项目文件 - const sourceWatcher = new FileWatcher(); - sourceWatcher.start({ - projectRoot: project.rootPath, - config, - onEvent: (event: WatchedEvent) => { - logger.info({ projectId: project.id, type: event.type, filePath: event.filePath }, '文件事件入库'); - this.options.store.insertEvent({ ...event, projectId: project.id }); - }, - onReady: () => { - logger.info({ projectId: project.id, projectRoot: project.rootPath }, '项目文件监控已就绪'); - }, - onError: (err) => { - logger.warn({ projectId: project.id, err }, '文件监控错误'); - }, - }); - watchers.push(sourceWatcher); - - // 监听归档目录,实时通知 UI 刷新文件树 - const archiveRoot = getArchiveRoot(project); - if (archiveRoot !== project.rootPath) { - const archiveWatcher = new FileWatcher(); - archiveWatcher.start({ - projectRoot: archiveRoot, - config: { include: [], exclude: [], categories: config.categories, docTypes: config.docTypes }, - onEvent: (event: WatchedEvent) => { - this.ipcServer?.broadcast('archive-tree-changed', { - projectId: project.id, - type: event.type, - filePath: event.filePath, - }); - }, - onError: (err) => { - logger.warn({ projectId: project.id, archiveRoot, err }, '归档目录监控错误'); - }, - }); - watchers.push(archiveWatcher); - } - - this.watchers.set(project.id, watchers); - } - - unwatchProject(projectId: string): void { - const watchers = this.watchers.get(projectId); - if (!watchers) return; - for (const watcher of watchers) { - watcher.stop(); - } - this.watchers.delete(projectId); - } -} + + private async checkRemoteModels(): Promise { + const [llm, multimodal] = await Promise.all([ + this.remoteModelChecker.checkLlm({ + apiKey: this.options.apiKey, + apiUrl: this.options.apiUrl, + provider: this.options.provider, + model: this.options.model, + headers: this.options.headers, + }), + this.remoteModelChecker.checkMultimodal(this.options.everos ?? {}), + ]); + this.remoteModelStatus = { llm, multimodal }; + } + + private startRemoteModelChecks(): void { + if (this.remoteModelCheckTimer) return; + this.remoteModelCheckTimer = setInterval(() => { + if (!this.running) return; + this.checkRemoteModels().catch(err => { + logger.warn({ err }, '远端模型状态检测失败'); + }); + }, 30000); + } + + /** + * 启动 EverOS + MCP Server。 + * + * 可被 start() 和 updateConfig() 调用;已启动或正在启动时不会重复执行。 + */ + private async startEverOS(): Promise { + if (this.everosStarting) return; + this.everosStarting = true; + this.everosState = 'starting'; + this.everosError = null; + + // EverOS 必须依赖远端 LLM 配置才能启动,且要求 OpenAI 兼容协议 + const effectiveLlmUrl = + (this.options.apiUrl ? cleanBaseUrl(this.options.apiUrl) : undefined) || + this.getDefaultLlmBaseUrl(this.options.provider); + if (!this.options.apiKey || !effectiveLlmUrl) { + const providerHint = + this.options.provider === 'anthropic' + ? '当前 Provider 为 Anthropic,EverOS LLM 需要 OpenAI 兼容的 Base URL,请在设置页填写代理地址或切换到 OpenAI 兼容 Provider。' + : '请先在设置页填写 API Key 和 Base URL。'; + const message = `EverOS 需要远端 LLM 配置:${providerHint}`; + logger.warn(message); + this.everosState = 'error'; + this.everosError = message; + this.everosStarting = false; + return; + } + + try { + if (this.everosService && this.everosMcpUrl) { + this.everosState = 'running'; + return; + } + + // 清理之前失败或未完成的实例 + if (this.everosService) { + this.everosService.stop(); + this.everosService = null; + } + await this.everosMcpServer?.stop(); + this.everosMcpServer = null; + this.everosMcpUrl = null; + this.everosHttpUrl = null; + + const submodulePath = join(__dirname, '..', '..', 'vendor', 'everos'); + this.everosService = new EverOSService({ + submodulePath, + env: this.buildEverOSEnv(), + }); + const everosUrl = await this.everosService.start(); + this.handlerContext.everosUrl = everosUrl; + this.everosHttpUrl = everosUrl; + this.everosMcpServer = new EverOSMcpServer({ + everosUrl, + queue: this.memoryWriteQueue, + onMemoryOwners: (projectId, owners) => { + for (const { ownerId, ownerType, displayName } of owners) { + this.options.store.recordMemoryOwner(projectId, ownerId, ownerType, displayName); + } + }, + }); + this.everosMcpUrl = await this.everosMcpServer.start(); + this.serviceRegistry.setMemoryMcpUrl(this.everosMcpUrl); + this.everosState = 'running'; + this.everosError = null; + logger.info({ everosUrl, mcpUrl: this.everosMcpUrl }, 'EverOS 记忆基础设施已启动'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.warn({ err }, `EverOS 未启动: ${message}`); + this.everosState = 'error'; + this.everosError = message; + // 记忆基础设施失败不应阻塞 daemon 其余功能 + this.everosService = null; + this.everosMcpServer = null; + this.everosMcpUrl = null; + this.everosHttpUrl = null; + } finally { + this.everosStarting = false; + } + } + + updateConfig(config: { + apiKey?: string; + apiUrl?: string; + provider?: 'anthropic' | 'openai'; + model?: string; + headers?: Record; + scanCron?: string; + llmRequestsPerMinute?: number; + embeddingModel?: string; + rerankModel?: string; + everos?: import('./config/daemon-config.js').EverOSConfig; + }): void { + const persisted: import('./config/daemon-config.js').DaemonPersistedConfig = {}; + + const isChanged = (oldValue: T, newValue: T | undefined): boolean => + newValue !== undefined && newValue !== oldValue; + + const memoryConfigChanged = + isChanged(this.options.apiKey, config.apiKey) || + isChanged(this.options.apiUrl, config.apiUrl) || + isChanged(this.options.provider, config.provider) || + isChanged(this.options.model, config.model) || + isChanged(this.options.embeddingModel, config.embeddingModel) || + isChanged(this.options.rerankModel, config.rerankModel) || + (config.everos !== undefined && + JSON.stringify(this.options.everos) !== JSON.stringify(config.everos)); + + if (config.apiKey !== undefined) { + this.options.apiKey = config.apiKey; + persisted.apiKey = config.apiKey; + } + if (config.apiUrl !== undefined) { + this.options.apiUrl = config.apiUrl; + persisted.apiUrl = config.apiUrl; + } + if (config.provider !== undefined) { + this.options.provider = config.provider; + persisted.provider = config.provider; + } + if (config.model !== undefined) { + this.options.model = config.model; + persisted.model = config.model; + } + if (config.headers !== undefined) { + this.options.headers = config.headers; + persisted.headers = config.headers; + } + if (config.scanCron !== undefined) { + this.options.scanCron = config.scanCron; + this.scanJob?.stop(); + this.scanJob = schedule(config.scanCron, () => this.scanService.scanAllProjects()); + persisted.scanCron = config.scanCron; + } + if (config.llmRequestsPerMinute !== undefined) { + this.options.llmRequestsPerMinute = config.llmRequestsPerMinute; + persisted.llmRequestsPerMinute = config.llmRequestsPerMinute; + } + + const embeddingModelChanged = isChanged(this.options.embeddingModel, config.embeddingModel); + const rerankModelChanged = isChanged(this.options.rerankModel, config.rerankModel); + if (config.embeddingModel !== undefined) { + this.options.embeddingModel = config.embeddingModel; + persisted.embeddingModel = config.embeddingModel; + } + if (config.rerankModel !== undefined) { + this.options.rerankModel = config.rerankModel; + persisted.rerankModel = config.rerankModel; + } + if (config.everos !== undefined) { + this.options.everos = config.everos; + persisted.everos = config.everos; + } + + // 本地模型名变更时替换 manager 实例,使新模型在下次启动时生效 + if (embeddingModelChanged || rerankModelChanged) { + this.localModelManager.stop(); + this.localModelManager = new LocalModelServiceManager({ + embeddingModel: this.options.embeddingModel, + rerankModel: this.options.rerankModel, + }); + this.handlerContext.localModelManager = this.localModelManager; + } + + if (Object.keys(persisted).length > 0) { + saveDaemonConfig(persisted); + } + + // 记忆相关配置真正发生变化时:先停掉 EverOS,再启动本地模型服务,最后用新 env 重启 EverOS + if (this.running && memoryConfigChanged) { + this.stopEverOS().then(() => + this.localModelManager + .start() + .then(async () => { + const status = this.localModelManager.getStatus(); + const ready = status.embedding.state === 'running' && status.rerank.state === 'running'; + if (ready) { + await this.startEverOS(); + } else { + logger.warn({ status }, '配置更新后本地模型服务未完全就绪,EverOS 保持停止'); + } + }) + .catch(err => { + const message = err instanceof Error ? err.message : String(err); + logger.warn({ err }, `配置更新后本地模型服务/EverOS 启动失败: ${message}`); + }) + ); + } + + // 远端模型配置变化后刷新连通性检测 + const remoteConfigChanged = + config.apiKey !== undefined || + config.apiUrl !== undefined || + config.provider !== undefined || + config.model !== undefined || + config.everos !== undefined; + if (this.running && remoteConfigChanged) { + this.checkRemoteModels().catch(err => logger.warn({ err }, '配置更新后远端模型检测失败')); + } + } + + private async stopEverOS(): Promise { + if (this.everosStarting) { + // 正在启动时避免竞态,简单置标记让启动完成后自然结束 + this.everosStarting = false; + } + this.everosState = 'idle'; + this.everosError = null; + this.everosHttpUrl = null; + this.everosMcpUrl = null; + this.handlerContext.everosUrl = undefined; + await this.everosMcpServer?.stop(); + this.everosMcpServer = null; + this.everosService?.stop(); + this.everosService = null; + } + + private buildEverOSEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + const agent = { + apiKey: this.options.apiKey, + apiUrl: this.options.apiUrl, + model: this.options.model, + }; + const cfg = this.options.everos ?? {}; + + // Embedding / Rerank:使用本地模型服务,未就绪时回退到 Agent 通用配置 + // 本地 infinity_emb 使用 --url-prefix /v1,所以传给 EverOS 的 base_url 需要带 /v1 后缀 + const embedKey = agent.apiKey ?? 'local'; + const rawEmbedUrl = this.localModelManager.getEmbeddingUrl() ?? agent.apiUrl; + const embedUrl = rawEmbedUrl ? this.ensureLocalModelBaseUrl(rawEmbedUrl) : undefined; + const embedModel = this.options.embeddingModel ?? DEFAULT_EMBEDDING_MODEL; + if (embedKey) env.EVEROS_EMBEDDING__API_KEY = embedKey; + if (embedUrl) env.EVEROS_EMBEDDING__BASE_URL = embedUrl; + env.EVEROS_EMBEDDING__MODEL = embedModel; + env.EVEROS_EMBEDDING__DIM = String(this.embeddingDim ?? getEmbeddingModelDimension(embedModel)); + + const rerankKey = agent.apiKey ?? 'local'; + const rawRerankUrl = this.localModelManager.getRerankUrl() ?? agent.apiUrl; + const rerankUrl = rawRerankUrl ? this.ensureLocalModelBaseUrl(rawRerankUrl) : undefined; + const rerankModel = this.options.rerankModel ?? DEFAULT_RERANK_MODEL; + if (rerankKey) env.EVEROS_RERANK__API_KEY = rerankKey; + if (rerankUrl) env.EVEROS_RERANK__BASE_URL = rerankUrl; + env.EVEROS_RERANK__MODEL = rerankModel; + // 本地 rerank 使用 infinity_emb 的 OpenAI-compatible /v1/rerank,显式指定 vllm provider + // 避免 EverOS 按 host 推断成 deepinfra + if (this.localModelManager.getRerankUrl()) { + env.EVEROS_RERANK__PROVIDER = 'vllm'; + } + + // LLM:EverOS 的 LLM 客户端使用 OpenAI 兼容协议;优先使用用户填写的 Base URL, + // OpenAI 官方 provider 留空时补全默认值,Anthropic 原生 API 不兼容,不自动补全 + const effectiveLlmUrl = + (agent.apiUrl ? cleanBaseUrl(agent.apiUrl) : undefined) || + this.getDefaultLlmBaseUrl(this.options.provider); + if (agent.apiKey) env.EVEROS_LLM__API_KEY = agent.apiKey; + if (effectiveLlmUrl) env.EVEROS_LLM__BASE_URL = effectiveLlmUrl; + if (agent.model) env.EVEROS_LLM__MODEL = agent.model; + + // memorize 流水线可能调用 LLM,若 LLM 无响应会长期占用 per-session lock; + // 将 lock 超时从默认 360s 降到 120s,避免单个卡住的调用阻塞同一 MR session 的后续记忆写入 + env.EVEROS_MEMORIZE__SESSION_LOCK_TIMEOUT_SECONDS = '120'; + + logger.info( + { + provider: this.options.provider, + baseUrl: effectiveLlmUrl ?? 'unset', + model: agent.model || 'default', + }, + '构建 EverOS LLM 环境变量' + ); + + // Multimodal:仅使用显式配置,未配置时不回退到 Agent LLM + const mmKey = cfg.multimodalApiKey; + const mmUrl = cfg.multimodalBaseUrl; + const mmModel = cfg.multimodalModel; + if (mmKey) env.EVEROS_MULTIMODAL__API_KEY = mmKey; + if (mmUrl) env.EVEROS_MULTIMODAL__BASE_URL = mmUrl; + if (mmModel) env.EVEROS_MULTIMODAL__MODEL = mmModel; + + return env; + } + + private getDefaultLlmBaseUrl(provider?: string): string | undefined { + // EverOS 的 LLM 模块只支持 OpenAI 兼容协议;Anthropic 原生 /v1/messages 不兼容 + if (provider === 'openai') { + return 'https://api.openai.com/v1'; + } + return undefined; + } + + /** + * 确保本地模型服务的 base_url 带 /v1 前缀。 + * + * infinity_emb 启动时带 --url-prefix /v1,OpenAI SDK 会再拼 /embeddings 或 /rerank; + * 如果直接传裸地址,EverOS 会请求 /embeddings 而不是 /v1/embeddings,导致 404。 + */ + private ensureLocalModelBaseUrl(url: string): string { + const trimmed = url.replace(/\/+$/, ''); + if (trimmed.endsWith('/v1')) { + return trimmed; + } + return `${trimmed}/v1`; + } + + private llmRequestInterval(): number { + const rpm = this.options.llmRequestsPerMinute ?? 10; + if (rpm <= 0) return 6000; + return Math.ceil(60000 / rpm); + } + + private async handleIpc(method: string, params: unknown): Promise { + const handler = handlers[method]; + if (!handler) throw new Error(`未知 method: ${method}`); + return handler(this.handlerContext, params); + } + + watchProject(project: Project): void { + if (this.watchers.has(project.id)) return; + + const config = loadProjectConfig(project.rootPath, project.archiveRoot); + const watchers: FileWatcher[] = []; + + // 监听原项目文件 + const sourceWatcher = new FileWatcher(); + sourceWatcher.start({ + projectRoot: project.rootPath, + config, + onEvent: (event: WatchedEvent) => { + logger.info( + { projectId: project.id, type: event.type, filePath: event.filePath }, + '文件事件入库' + ); + this.options.store.insertEvent({ ...event, projectId: project.id }); + }, + onReady: () => { + logger.info({ projectId: project.id, projectRoot: project.rootPath }, '项目文件监控已就绪'); + }, + onError: err => { + logger.warn({ projectId: project.id, err }, '文件监控错误'); + }, + }); + watchers.push(sourceWatcher); + + // 监听归档目录,实时通知 UI 刷新文件树 + const archiveRoot = getArchiveRoot(project); + if (archiveRoot !== project.rootPath) { + const archiveWatcher = new FileWatcher(); + archiveWatcher.start({ + projectRoot: archiveRoot, + config: { + include: [], + exclude: [], + categories: config.categories, + docTypes: config.docTypes, + }, + onEvent: (event: WatchedEvent) => { + this.ipcServer?.broadcast('archive-tree-changed', { + projectId: project.id, + type: event.type, + filePath: event.filePath, + }); + }, + onError: err => { + logger.warn({ projectId: project.id, archiveRoot, err }, '归档目录监控错误'); + }, + }); + watchers.push(archiveWatcher); + } + + this.watchers.set(project.id, watchers); + } + + unwatchProject(projectId: string): void { + const watchers = this.watchers.get(projectId); + if (!watchers) return; + for (const watcher of watchers) { + watcher.stop(); + } + this.watchers.delete(projectId); + } +} diff --git a/src/advance/file-watcher.ts b/src/advance/file-watcher.ts index 5d05fb2..a559bf9 100755 --- a/src/advance/file-watcher.ts +++ b/src/advance/file-watcher.ts @@ -3,7 +3,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { matchesProjectPathPatterns, type ProjectConfig } from './config/project-config'; import type { WatchedEvent, WatchEventType } from './types'; -import { logger } from '../core/logger'; +import { logger } from './core/logger'; import { logMemorySnapshot } from './classic/utils/memory-snapshot.js'; export interface FileWatcherOptions { @@ -85,15 +85,17 @@ export class FileWatcher { }; this.watcher - .on('add', (path) => emit('add', path)) - .on('change', (path) => emit('change', path)) - .on('unlink', (path) => emit('unlink', path)); + .on('add', path => emit('add', path)) + .on('change', path => emit('change', path)) + .on('unlink', path => emit('unlink', path)); if (onReady) { this.watcher.on('ready', onReady); } if (onError) { - this.watcher.on('error', (err: unknown) => onError(err instanceof Error ? err : new Error(String(err)))); + this.watcher.on('error', (err: unknown) => + onError(err instanceof Error ? err : new Error(String(err))) + ); } } diff --git a/src/gitlab/client.ts b/src/advance/gitlab/client.ts similarity index 88% rename from src/gitlab/client.ts rename to src/advance/gitlab/client.ts index e966392..92ebc53 100755 --- a/src/gitlab/client.ts +++ b/src/advance/gitlab/client.ts @@ -1,9 +1,9 @@ import { logger } from '../core/logger.js'; -import type { ProjectConfig } from '../types.js'; -interface GitLabApiOptions { +export interface GitLabClientOptions { baseUrl: string; token: string; + projectPath: string; } export class GitLabClient { @@ -11,21 +11,14 @@ export class GitLabClient { private token: string; private projectId: string; - constructor( - project: ProjectConfig, - options?: GitLabApiOptions - ) { - this.baseUrl = options?.baseUrl || project.gitlab.baseUrl; - this.token = options?.token || project.gitlab.token; + constructor(options: GitLabClientOptions) { + this.baseUrl = options.baseUrl; + this.token = options.token; // URL-encode project path for GitLab API - this.projectId = encodeURIComponent(project.gitlab.projectPath); + this.projectId = encodeURIComponent(options.projectPath); } - private async request( - method: string, - endpoint: string, - body?: unknown - ): Promise { + private async request(method: string, endpoint: string, body?: unknown): Promise { const url = `${this.baseUrl}/api/v4${endpoint}`; const headers: Record = { @@ -55,7 +48,10 @@ export class GitLabClient { if (!response.ok) { const errorText = await response.text(); - logger.error({ status: response.status, body: errorText }, `GitLab API error: ${method} ${endpoint}`); + logger.error( + { status: response.status, body: errorText }, + `GitLab API error: ${method} ${endpoint}` + ); throw new Error(`GitLab API ${response.status}: ${errorText}`); } @@ -82,7 +78,10 @@ export class GitLabClient { if (!response.ok) { const errorText = await response.text(); - logger.error({ status: response.status, body: errorText }, `GitLab API error: GET(text) ${endpoint}`); + logger.error( + { status: response.status, body: errorText }, + `GitLab API error: GET(text) ${endpoint}` + ); throw new Error(`GitLab API ${response.status}: ${errorText}`); } @@ -117,10 +116,7 @@ export class GitLabClient { * Verify that the project is reachable with current credentials */ async verifyProject(): Promise { - await this.request( - 'GET', - `/projects/${this.projectId}` - ); + await this.request('GET', `/projects/${this.projectId}`); } /** @@ -172,10 +168,7 @@ export class GitLabClient { * List project labels */ async listLabels(): Promise { - return this.request( - 'GET', - `/projects/${this.projectId}/labels?per_page=100` - ); + return this.request('GET', `/projects/${this.projectId}/labels?per_page=100`); } /** @@ -208,10 +201,7 @@ export class GitLabClient { * Get single merge request */ async getMergeRequest(iid: number): Promise { - return this.request( - 'GET', - `/projects/${this.projectId}/merge_requests/${iid}` - ); + return this.request('GET', `/projects/${this.projectId}/merge_requests/${iid}`); } /** @@ -271,11 +261,7 @@ export class GitLabClient { /** * Add a note to an existing discussion on MR */ - async addDiscussionNote( - iid: number, - discussionId: string, - body: string - ): Promise { + async addDiscussionNote(iid: number, discussionId: string, body: string): Promise { return this.request( 'POST', `/projects/${this.projectId}/merge_requests/${iid}/discussions/${encodeURIComponent(discussionId)}/notes`, @@ -311,16 +297,12 @@ export class GitLabClient { title: string; description?: string; }): Promise { - return this.request( - 'POST', - `/projects/${this.projectId}/merge_requests`, - { - source_branch: params.sourceBranch, - target_branch: params.targetBranch, - title: params.title, - description: params.description || '', - } - ); + return this.request('POST', `/projects/${this.projectId}/merge_requests`, { + source_branch: params.sourceBranch, + target_branch: params.targetBranch, + title: params.title, + description: params.description || '', + }); } /** diff --git a/src/advance/ipc/handlers.ts b/src/advance/ipc/handlers.ts index e65d36b..d5aad9b 100755 --- a/src/advance/ipc/handlers.ts +++ b/src/advance/ipc/handlers.ts @@ -1,13 +1,24 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { getLogDir } from '../../core/platform'; -import { logger } from '../../core/logger'; +import { randomUUID } from 'node:crypto'; +import { + accessSync, + constants, + readFileSync, + writeFileSync, + existsSync, + mkdirSync, + unlinkSync, + renameSync, +} from 'node:fs'; +import { join, normalize, relative, resolve } from 'node:path'; +import { getLogDir } from '../core/platform'; +import { logger } from '../core/logger'; import type { MetadataStore } from '../store/metadata-store'; import type { ProjectRegistry } from '../project-registry'; import type { LlmClient } from '../llm/client'; import type { Project } from '../types'; -import { getArchiveRoot } from '../types'; -import { loadProjectConfig } from '../config/project-config'; +import { getArchiveRoot, isRoleConfigEnabled } from '../types'; +import { loadProjectConfig, projectConfigSchema } from '../config/project-config.js'; +import YAML from 'yaml'; import { loadDaemonConfig, saveDaemonConfig } from '../config/daemon-config.js'; import { DEFAULT_EMBEDDING_MODEL, @@ -23,7 +34,38 @@ import { } 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'; +import type { PipelineScheduler } from '../pipeline/pipeline-scheduler.js'; +import { regeneratePipelineDefinitionIfGenerated } from '../pipeline/default-pipeline.js'; +import { + getKnowledgeInboxDir, + listProjectKnowledge, + parseKnowledgeItem, + readKnowledgeDir, + writeSharedKnowledge, + type KnowledgeItem, +} from '../knowledge/knowledge-store.js'; + +/** 知识条目传输形状(正文截断,避免大文件全量过 IPC) */ +function toKnowledgeDto(item: KnowledgeItem) { + return { + id: item.frontmatter.id, + title: item.frontmatter.title, + category: item.frontmatter.category, + scope: item.frontmatter.scope, + confidence: item.frontmatter.confidence, + source: item.frontmatter.source, + tags: item.frontmatter.tags, + updated: item.frontmatter.updated, + root: item.root, + bodyPreview: item.body.slice(0, 500), + }; +} + +function normalizeHistoryLimit(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 100; + return Math.min(100, Math.max(1, Math.floor(parsed))); +} import type { LocalModelServiceManager } from '../classic/memory/local-model-service.js'; import type { ModelCapability } from '../classic/memory/model-server.js'; import { ReviewerManager } from '../classic/roles/reviewer-manager.js'; @@ -69,7 +111,7 @@ import type { RemoteModelChecker } from '../classic/memory/remote-model-checker. export interface HandlerContext { store: MetadataStore; registry: ProjectRegistry; - serviceRegistry: RoleServiceRegistry; + serviceRegistry: PipelineScheduler; /** 数据库文件路径,供子进程独立打开 metadata store */ dbPath: string; scanService?: ScanService; @@ -94,10 +136,13 @@ export interface HandlerContext { scanCron: string; llmRequestsPerMinute: number; everos: string; + agents?: import('../agents/registry.js').AgentSpec[]; }; watchProject?: (project: Project) => void; unwatchProject?: (projectId: string) => void; - /** EverOS HTTP URL,用于 memory.search 等 handler 直接访问 */ + /** MCP 门面地址(外部 Agent 接入点) */ + getMcpFacadeUrl?: () => string | null; + /** EverOS HTTP URL,用于 memory.search / 知识投影等直接访问 */ everosUrl?: string; /** 本地 Embedding/Rerank 模型服务管理器 */ localModelManager?: LocalModelServiceManager; @@ -122,6 +167,8 @@ export const handlers: Record Prom 'project.register': async (ctx, params) => { const project = ctx.registry.register(params.rootPath, params.archiveRoot); ctx.watchProject?.(project); + // 新项目接入管线调度(角色启用后立即可被触发) + ctx.serviceRegistry?.reloadProject(project.id); // 全量扫描在独立 worker 中异步执行,不阻塞注册返回,也不阻塞 daemon IPC if (ctx.scanService) { try { @@ -158,6 +205,8 @@ export const handlers: Record Prom 'project.unregister': async (ctx, params) => { ctx.unwatchProject?.(params.projectId); + // 先摘除管线触发器并终止节点实例,再注销 + ctx.serviceRegistry?.unloadProject(params.projectId); ctx.registry.unregister(params.projectId); return { success: true }; }, @@ -202,9 +251,16 @@ export const handlers: Record Prom } return { ...p, + gitlab: p.gitlab ? { ...p.gitlab, token: '' } : undefined, + hasGitlabToken: Boolean(p.gitlab?.token), ...counts, healthScore, lastScannedAt: p.lastScannedAt, + roleStates: { + reviewer: isRoleConfigEnabled(p.roles?.reviewer), + maintainer: isRoleConfigEnabled(p.roles?.maintainer), + archiver: isRoleConfigEnabled(p.roles?.archiver), + }, }; }); }, @@ -213,7 +269,12 @@ export const handlers: Record Prom const project = ctx.registry.get(params.projectId); if (!project) throw new Error('项目未注册'); const counts = ctx.store.getProjectCounts(project.id); - return { ...project, ...counts }; + return { + ...project, + gitlab: project.gitlab ? { ...project.gitlab, token: '' } : undefined, + hasGitlabToken: Boolean(project.gitlab?.token), + ...counts, + }; }, 'project.scan': async (ctx, params) => { @@ -274,23 +335,77 @@ export const handlers: Record Prom const dir = getArchiveRoot(project); mkdirSync(dir, { recursive: true }); const path = join(dir, 'config.yaml'); - writeFileSync(path, params.content, 'utf-8'); + const content = typeof params.content === 'string' ? params.content : ''; + const parsed = YAML.parse(content); + projectConfigSchema.parse(parsed); + const temporaryPath = `${path}.tmp-${randomUUID()}`; + try { + writeFileSync(temporaryPath, content, 'utf-8'); + renameSync(temporaryPath, path); + } finally { + if (existsSync(temporaryPath)) unlinkSync(temporaryPath); + } + ctx.unwatchProject?.(params.projectId); + const updatedProject = ctx.registry.get(params.projectId); + if (updatedProject) ctx.watchProject?.(updatedProject); + return { success: true }; + }, + + 'project.archive-root.update': async (ctx, params) => { + const project = ctx.registry.get(params.projectId); + if (!project) throw new Error('项目未注册'); + const archiveRootInput = + typeof params.archiveRoot === 'string' ? params.archiveRoot.trim() : ''; + const archiveRoot = archiveRootInput ? normalize(resolve(archiveRootInput)) : null; + if (archiveRoot) { + try { + mkdirSync(archiveRoot, { recursive: true }); + accessSync(archiveRoot, constants.R_OK | constants.W_OK); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`归档位置不可读写: ${archiveRoot}: ${message}`); + } + } + ctx.store.updateProjectArchiveRoot(params.projectId, archiveRoot); + ctx.unwatchProject?.(params.projectId); + const updatedProject = ctx.registry.get(params.projectId); + if (updatedProject) ctx.watchProject?.(updatedProject); + ctx.serviceRegistry.reloadProject(params.projectId); return { success: true }; }, 'action.history': async (ctx, params) => { if (params.projectId === 'all') { const projects = ctx.registry.list(); - return projects.flatMap(p => ctx.store.listActionHistory(p.id)); + const limit = normalizeHistoryLimit(params.limit); + const items = ctx.store.listActionHistoryAll(limit); + const roots = new Map(projects.map(project => [project.id, project.rootPath])); + return items.map(item => ({ + ...item, + sourcePath: relative(roots.get(item.projectId) ?? '', item.sourcePath) || '.', + targetPath: item.targetPath + ? relative(roots.get(item.projectId) ?? '', item.targetPath) || '.' + : undefined, + })); } - return ctx.store.listActionHistory(params.projectId); + const project = ctx.registry.get(params.projectId); + if (!project) throw new Error('项目未注册'); + const history = ctx.store.listActionHistory( + params.projectId, + normalizeHistoryLimit(params.limit) + ); + return history.map(item => ({ + ...item, + sourcePath: relative(project.rootPath, item.sourcePath) || '.', + targetPath: item.targetPath ? relative(project.rootPath, item.targetPath) || '.' : undefined, + })); }, 'action.undo': async (ctx, params) => { const project = ctx.registry.get(params.projectId); if (!project) throw new Error('项目未注册'); const executor = new UndoExecutor({ store: ctx.store }); - return executor.undo(params.actionId); + return executor.undo(params.actionId, params.projectId); }, 'daemon.config': async ctx => { @@ -312,6 +427,7 @@ export const handlers: Record Prom embeddingModel: persisted.embeddingModel ?? DEFAULT_EMBEDDING_MODEL, rerankModel: persisted.rerankModel ?? DEFAULT_RERANK_MODEL, everos: persisted.everos ? JSON.stringify(persisted.everos) : '', + agents: persisted.agents ?? [], }; }, @@ -424,7 +540,13 @@ export const handlers: Record Prom 'project.gitlab.config.get': async (ctx, params) => { const project = ctx.registry.get(params.projectId); if (!project) throw new Error('项目未注册'); - return { gitlab: project.gitlab ?? null }; + const includeSecret = params.includeSecret === true; + return { + gitlab: project.gitlab + ? { ...project.gitlab, token: includeSecret ? project.gitlab.token : '' } + : null, + hasToken: Boolean(project.gitlab?.token), + }; }, 'project.gitlab.config.update': async (ctx, params) => { @@ -438,7 +560,7 @@ export const handlers: Record Prom const updated: NonNullable = { baseUrl: gitlab.baseUrl, projectPath: gitlab.projectPath, - token: gitlab.token ?? project.gitlab?.token ?? '', + token: gitlab.token?.trim() ? gitlab.token.trim() : (project.gitlab?.token ?? ''), defaultBranch: gitlab.defaultBranch ?? project.gitlab?.defaultBranch ?? 'main', }; ctx.store.updateProjectGitlabConfig(params.projectId, updated); @@ -814,6 +936,7 @@ export const handlers: Record Prom queuedJobs: 0, providers: [], }, + mcpFacade: { url: ctx.getMcpFacadeUrl?.() ?? null }, } satisfies DaemonStatus; }, @@ -879,7 +1002,12 @@ export const handlers: Record Prom } const manager = createRoleManager(role, ctx.store); await manager.updateConfig(projectId, config); - // 触发服务重启由后续 task 补齐 + // 角色配置变更:若管线正本仍是自动生成件则同步重建,随后重排调度并重启节点实例 + const project = ctx.store.getProject(projectId); + if (project) { + regeneratePipelineDefinitionIfGenerated(project); + } + ctx.serviceRegistry?.reloadProject(projectId); return { success: true }; }, @@ -932,7 +1060,12 @@ export const handlers: Record Prom 'role.service.restart': async (ctx, params) => { const { role, projectId } = params as { role: Role; projectId?: string }; if (!ctx.serviceRegistry) throw new Error('角色服务注册表未初始化'); - await ctx.serviceRegistry.restartProject(role, projectId ?? ''); + if (projectId) { + await ctx.serviceRegistry.restartProject(role, projectId); + } else { + // 未指定项目:整角色重启(重建全部节点实例,获取最新进程环境) + await ctx.serviceRegistry.restartRole(role); + } return { success: true }; }, @@ -941,6 +1074,64 @@ export const handlers: Record Prom if (!ctx.serviceRegistry) throw new Error('角色服务注册表未初始化'); return ctx.serviceRegistry.getStatus(role); }, + + 'pipeline.get': async (ctx, params) => { + const { projectId } = params as { projectId: string }; + if (!ctx.serviceRegistry) throw new Error('管线调度器未初始化'); + return ctx.serviceRegistry.getProjectPipeline(projectId); + }, + + 'pipeline.update': async (ctx, params) => { + const { projectId, definition } = params as { projectId: string; definition: unknown }; + if (!ctx.serviceRegistry) throw new Error('管线调度器未初始化'); + ctx.serviceRegistry.updateProjectPipeline(projectId, definition); + return { success: true }; + }, + + 'pipeline.runs': async (ctx, params) => { + const { projectId, limit } = params as { projectId: string; limit?: number }; + if (!ctx.serviceRegistry) throw new Error('管线调度器未初始化'); + return ctx.serviceRegistry.listPipelineRuns(projectId, limit); + }, + + 'knowledge.list': async (ctx, params) => { + const { projectId } = params as { projectId: string }; + const project = ctx.store.getProject(projectId); + if (!project) throw new Error(`项目不存在: ${projectId}`); + return { + items: listProjectKnowledge(project).map(toKnowledgeDto), + inbox: readKnowledgeDir(getKnowledgeInboxDir(project), 'shared').map(toKnowledgeDto), + }; + }, + + 'knowledge.approve': async (ctx, params) => { + const { projectId, knowledgeId } = params as { projectId: string; knowledgeId: string }; + if (!/^[a-z0-9][a-z0-9-]*$/.test(knowledgeId)) { + throw new Error(`非法的知识 id: ${knowledgeId}`); + } + const project = ctx.store.getProject(projectId); + if (!project) throw new Error(`项目不存在: ${projectId}`); + const inboxDir = getKnowledgeInboxDir(project); + const source = join(inboxDir, `${knowledgeId}.md`); + if (!existsSync(source)) throw new Error(`草稿不存在: ${knowledgeId}`); + // 人审通过:移入共享正本(随项目入库) + const item = parseKnowledgeItem(readFileSync(source, 'utf-8'), 'shared', source); + writeSharedKnowledge(project, { frontmatter: item.frontmatter, body: item.body }); + unlinkSync(source); + return { success: true }; + }, + + 'knowledge.dismiss': async (ctx, params) => { + const { projectId, knowledgeId } = params as { projectId: string; knowledgeId: string }; + if (!/^[a-z0-9][a-z0-9-]*$/.test(knowledgeId)) { + throw new Error(`非法的知识 id: ${knowledgeId}`); + } + const project = ctx.store.getProject(projectId); + if (!project) throw new Error(`项目不存在: ${projectId}`); + const target = join(getKnowledgeInboxDir(project), `${knowledgeId}.md`); + if (existsSync(target)) unlinkSync(target); + return { success: true }; + }, }; function mapEverOSSearchItemToMemoryEntry(item: EverOSSearchItem): MemoryEntry { @@ -979,9 +1170,7 @@ function getArchiverProviderOrchestrator( ctx: HandlerContext ): Pick { return ( - ctx.codeGraphService ?? - ctx.archiverProviderOrchestrator ?? - new ArchiverProviderOrchestrator() + ctx.codeGraphService ?? ctx.archiverProviderOrchestrator ?? new ArchiverProviderOrchestrator() ); } diff --git a/src/advance/ipc/role-ipc-registry.ts b/src/advance/ipc/role-ipc-registry.ts deleted file mode 100755 index e0e3ead..0000000 --- a/src/advance/ipc/role-ipc-registry.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { IpcMain } from 'electron'; -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 { MetadataStore } from '../store/metadata-store.js'; -import type { RoleServiceRegistry } from '../classic/role-service-registry.js'; -import type { Role, RoleConfig } from '../types.js'; -import type { IRoleManager } from '../classic/roles/role-manager.js'; - -export interface RoleIPCContext { - ipc: IpcMain; - store: MetadataStore; - serviceRegistry: RoleServiceRegistry; -} - -function createRoleManager(role: Role, store: MetadataStore): IRoleManager { - switch (role) { - case 'reviewer': - return new ReviewerManager(store); - case 'maintainer': - return new MaintainerManager(store); - case 'archiver': - return new ArchiverManager(store); - default: - throw new Error(`未支持的角色: ${role}`); - } -} - -export function registerRoleIPCHandlers(context: RoleIPCContext): void { - const { ipc, store, serviceRegistry } = context; - const managers = new Map(); - const roles: Role[] = ['reviewer', 'maintainer', 'archiver']; - - for (const role of roles) { - 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.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('role.service.start', async (_event, { role }: { role: Role }) => { - await serviceRegistry.start(role); - return { success: true }; - }); - - ipc.handle('role.service.stop', async (_event, { role }: { role: Role }) => { - await serviceRegistry.stop(role); - 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, ''); - } - return { success: true }; - } - ); - - ipc.handle('role.service.status', async (_event, { role }: { role: Role }) => { - return serviceRegistry.getStatus(role); - }); -} diff --git a/src/advance/ipc/server.ts b/src/advance/ipc/server.ts index c26665d..c0f1369 100755 --- a/src/advance/ipc/server.ts +++ b/src/advance/ipc/server.ts @@ -1,6 +1,6 @@ import { createServer, Server, Socket } from 'node:net'; import { unlinkSync, existsSync, chmodSync } from 'node:fs'; -import { logger } from '../../core/logger'; +import { logger } from '../core/logger'; import type { IpcRequest, IpcResponse, IpcPushEvent } from './types'; export interface IpcServerOptions { @@ -24,10 +24,10 @@ export class IpcServer { } } - this.server = createServer((socket) => { + this.server = createServer(socket => { this.sockets.add(socket); let buffer = ''; - socket.on('data', (data) => { + socket.on('data', data => { buffer += data.toString('utf-8'); let idx; while ((idx = buffer.indexOf('\n')) !== -1) { @@ -37,7 +37,7 @@ export class IpcServer { } }); socket.on('close', () => this.sockets.delete(socket)); - socket.on('error', (err) => logger.warn({ err }, 'IPC socket 错误')); + socket.on('error', err => logger.warn({ err }, 'IPC socket 错误')); }); this.server.listen(this.options.socketPath, () => { @@ -55,7 +55,7 @@ export class IpcServer { } stop(): Promise { - return new Promise((resolve) => { + return new Promise(resolve => { if (!this.server) { resolve(); return; @@ -80,7 +80,7 @@ export class IpcServer { const line = JSON.stringify(msg) + '\n'; for (const socket of this.sockets) { if (socket.writable) { - socket.write(line, (err) => { + socket.write(line, err => { if (err) { logger.warn({ err, event }, 'IPC broadcast 写入失败'); } @@ -99,7 +99,10 @@ export class IpcServer { } if (typeof request.id !== 'string' || typeof request.method !== 'string') { - this.send(socket, { id: 'unknown', error: { code: 'INVALID_REQUEST', message: '请求缺少 id 或 method 字段' } }); + this.send(socket, { + id: 'unknown', + error: { code: 'INVALID_REQUEST', message: '请求缺少 id 或 method 字段' }, + }); return; } @@ -122,7 +125,7 @@ export class IpcServer { logger.warn({ response }, 'IPC socket 不可写,跳过发送'); return; } - socket.write(JSON.stringify(response) + '\n', (err) => { + socket.write(JSON.stringify(response) + '\n', err => { if (err) { logger.warn({ err, response }, 'IPC socket 写入失败'); } diff --git a/src/advance/knowledge/knowledge-distill.ts b/src/advance/knowledge/knowledge-distill.ts new file mode 100644 index 0000000..7b4dc65 --- /dev/null +++ b/src/advance/knowledge/knowledge-distill.ts @@ -0,0 +1,113 @@ +/** + * 知识蒸馏管线:EverOS 经验记忆 → 策展知识候选(人审草稿箱)。 + * + * 承接旧版 learn 循环的精神(经验 → 策展知识),但以"人工检查点"为闸门: + * LLM 产出的候选一律写入 knowledge-inbox/(source: distilled), + * 由人类在 Knowledge tab 审阅后批准才进入正本。 + */ + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { z } from 'zod'; +import { logger } from '../core/logger.js'; +import type { LlmClient } from '../llm/client.js'; +import { FilePromptLoader } from '../llm/prompts/loader.js'; +import type { Project } from '../types.js'; +import { + getKnowledgeInboxDir, + listProjectKnowledge, + readKnowledgeDir, + serializeKnowledgeItem, + type KnowledgeFrontmatter, +} from './knowledge-store.js'; + +const candidateSchema = z.object({ + id: z + .string() + .regex(/^[a-z0-9][a-z0-9-]*$/) + .catch(() => `c-${randomUUID().slice(0, 8)}`), + title: z.string().min(1), + category: z.enum(['convention', 'architecture', 'domain', 'risk', 'stack', 'graph']), + confidence: z.enum(['high', 'medium', 'low']).default('low'), + tags: z.array(z.string()).default([]), + body: z.string().min(1), +}); + +const distillResponseSchema = z.object({ + candidates: z.array(candidateSchema).default([]), +}); + +export interface DistillResult { + candidates: number; + written: string[]; + skippedDuplicates: number; +} + +/** + * 从项目经验记忆蒸馏知识候选,写入人审草稿箱。 + * experiences:EverOS 中召回的近期经验文本(由调用方收集)。 + */ +export async function distillKnowledgeCandidates( + project: Project, + llmClient: LlmClient, + experiences: string[] +): Promise { + const result: DistillResult = { candidates: 0, written: [], skippedDuplicates: 0 }; + if (experiences.length === 0) return result; + + const promptLoader = new FilePromptLoader(); + const prompt = promptLoader.load('knowledge-distill', { + experiences: experiences.join('\n\n---\n\n'), + }); + + const raw = await llmClient.completeJson(prompt, '你是项目智库的蒸馏器。', { + type: 'object', + properties: { candidates: { type: 'array' } }, + required: ['candidates'], + }); + let parsedRaw: unknown; + try { + parsedRaw = JSON.parse(raw); + } catch { + logger.warn('[KnowledgeDistill] 蒸馏结果不是合法 JSON,丢弃本轮'); + return result; + } + const parsed = distillResponseSchema.safeParse(parsedRaw); + if (!parsed.success) { + logger.warn(`[KnowledgeDistill] 蒸馏结果不符合 schema,丢弃本轮`); + return result; + } + + // 与既有正本及待审草稿都去重(同 id 不重复入草稿箱) + const existingIds = new Set([ + ...listProjectKnowledge(project).map(item => item.frontmatter.id), + ...readKnowledgeDir(getKnowledgeInboxDir(project), 'shared').map(item => item.frontmatter.id), + ]); + const inboxDir = getKnowledgeInboxDir(project); + mkdirSync(inboxDir, { recursive: true }); + + for (const candidate of parsed.data.candidates) { + result.candidates += 1; + if (existingIds.has(candidate.id)) { + result.skippedDuplicates += 1; + continue; + } + existingIds.add(candidate.id); + const frontmatter: KnowledgeFrontmatter = { + id: candidate.id, + title: candidate.title, + category: candidate.category, + scope: 'project', + confidence: candidate.confidence, + source: 'distilled', + tags: candidate.tags, + updated: new Date().toISOString().slice(0, 10), + }; + const filePath = join(inboxDir, `${frontmatter.id}.md`); + writeFileSync(filePath, serializeKnowledgeItem({ frontmatter, body: candidate.body }), 'utf-8'); + result.written.push(filePath); + } + + return result; +} diff --git a/src/advance/knowledge/knowledge-projection.ts b/src/advance/knowledge/knowledge-projection.ts new file mode 100644 index 0000000..374cf80 --- /dev/null +++ b/src/advance/knowledge/knowledge-projection.ts @@ -0,0 +1,124 @@ +/** + * 知识投影:策展正本(Markdown 文件)→ EverOS 记忆索引。 + * + * 方向单向:正本(人类/蒸馏落盘的文件)→ EverOS 检索层。 + * 以内容哈希去重(knowledge_projection 表),EverOS 侧数据可随时视为可重建: + * 清空记忆后重跑投影即可恢复检索层。 + */ + +import { createHash } from 'node:crypto'; +import type Database from 'better-sqlite3'; +import { logger } from '../core/logger.js'; +import { everosMemoryAddMessages, everosMemoryFlush } from '../classic/memory/everos-api.js'; +import type { EverOSAddMessage } from '../classic/memory/everos-api.js'; +import type { Project } from '../types.js'; +import { listProjectKnowledge, type KnowledgeItem } from './knowledge-store.js'; + +const APP_ID = 'codekeeper-advance'; +/** 知识投影写入 EverOS 时使用的 owner/会话约定(与 archiver 的 record_project_knowledge 同级) */ +const KNOWLEDGE_AGENT = 'knowledge-projection'; + +export interface ProjectionResult { + total: number; + synced: number; + skipped: number; + failed: number; +} + +/** 把项目双正本中发生变化的知识条目投影到 EverOS */ +export async function projectKnowledgeToEverOS( + db: Database.Database, + everosUrl: string, + project: Project +): Promise { + const items = listProjectKnowledge(project); + const result: ProjectionResult = { total: items.length, synced: 0, skipped: 0, failed: 0 }; + + const upsertState = db.prepare( + `INSERT INTO knowledge_projection (project_id, root, knowledge_id, content_hash, synced_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(project_id, root, knowledge_id) + DO UPDATE SET content_hash = excluded.content_hash, synced_at = excluded.synced_at` + ); + const getState = db.prepare( + 'SELECT content_hash FROM knowledge_projection WHERE project_id = ? AND root = ? AND knowledge_id = ?' + ); + const deleteState = db.prepare( + 'DELETE FROM knowledge_projection WHERE project_id = ? AND root = ? AND knowledge_id = ?' + ); + const syncedThisRound: KnowledgeItem[] = []; + + for (const item of items) { + const hash = hashItem(item); + const existing = getState.get(project.id, item.root, item.frontmatter.id) as + | { content_hash: string } + | undefined; + if (existing?.content_hash === hash) { + result.skipped += 1; + continue; + } + + try { + const message: EverOSAddMessage = { + senderId: KNOWLEDGE_AGENT, + role: 'assistant', + content: formatKnowledgeMessage(item), + timestamp: Date.now(), + }; + await everosMemoryAddMessages( + everosUrl, + { appId: APP_ID, projectId: project.id, sessionId: `knowledge-${project.id}` }, + [message] + ); + upsertState.run(project.id, item.root, item.frontmatter.id, hash, Date.now()); + syncedThisRound.push(item); + result.synced += 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`[KnowledgeProjection] 条目 ${item.frontmatter.id} 投影失败: ${message}`); + result.failed += 1; + } + } + + if (result.synced > 0) { + try { + await everosMemoryFlush(everosUrl, { + appId: APP_ID, + projectId: project.id, + sessionId: `knowledge-${project.id}`, + }); + } catch (error) { + // flush 失败:回滚本轮状态,下轮重新写入并 flush(避免消息滞留 buffer) + for (const item of syncedThisRound) { + deleteState.run(project.id, item.root, item.frontmatter.id); + } + result.synced = 0; + result.failed += syncedThisRound.length; + logger.warn({ err: error }, '[KnowledgeProjection] flush 失败,本轮状态已回滚'); + } + } + + return result; +} + +function hashItem(item: KnowledgeItem): string { + return createHash('sha256') + .update(JSON.stringify(item.frontmatter)) + .update('\n') + .update(item.body) + .digest('hex'); +} + +function formatKnowledgeMessage(item: KnowledgeItem): string { + const fm = item.frontmatter; + return [ + `[KNOWLEDGE:${fm.id}]`, + `title: ${fm.title}`, + `category: ${fm.category} | scope: ${fm.scope} | confidence: ${fm.confidence} | source: ${fm.source} | root: ${item.root}`, + fm.tags.length > 0 ? `tags: ${fm.tags.join(', ')}` : '', + '', + item.body, + ] + .filter(line => line !== '') + .join('\n'); +} diff --git a/src/advance/knowledge/knowledge-store.ts b/src/advance/knowledge/knowledge-store.ts new file mode 100644 index 0000000..998b34d --- /dev/null +++ b/src/advance/knowledge/knowledge-store.ts @@ -0,0 +1,150 @@ +/** + * 智库策展正本层(双正本)。 + * + * 唯一事实源是 Markdown + YAML frontmatter 文件: + * - 共享正本:/.codekeeper/knowledge/(随项目入库,团队可见) + * - 私有正本:~/.codekeeper/memory/knowledge//(本地私有) + * + * EverOS / CodeGraph 只是它们的可重建投影(见 knowledge-projection.ts)。 + */ + +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parse as parseYaml, stringify } from 'yaml'; +import { z } from 'zod'; +import { getAppStorageDir } from '../core/platform.js'; +import type { Project } from '../types.js'; + +/** 策展知识条目的 frontmatter */ +export const knowledgeFrontmatterSchema = z.object({ + id: z + .string() + .min(1) + .regex(/^[a-z0-9][a-z0-9-]*$/, 'id 必须是小写 slug'), + title: z.string().min(1), + category: z.enum(['convention', 'architecture', 'domain', 'risk', 'stack', 'graph']), + /** project=项目内共享;system=跨项目共性(写入时仍可放项目根,投影时上移) */ + scope: z.enum(['project', 'system']).default('project'), + confidence: z.enum(['high', 'medium', 'low']).default('medium'), + source: z.enum(['human', 'distilled', 'archiver']).default('human'), + tags: z.array(z.string()).default([]), + updated: z.string().min(1), +}); +export type KnowledgeFrontmatter = z.infer; + +export interface KnowledgeItem { + frontmatter: KnowledgeFrontmatter; + /** Markdown 正文 */ + body: string; + /** 来源根:shared=项目入库;local=本地私有 */ + root: 'shared' | 'local'; + filePath: string; +} + +export class KnowledgeStoreError extends Error { + constructor(message: string) { + super(message); + this.name = 'KnowledgeStoreError'; + } +} + +/** 共享正本目录(项目内,入库) */ +export function getSharedKnowledgeDir(project: Project): string { + return join(project.rootPath, '.codekeeper', 'knowledge'); +} + +/** 私有正本目录(本地);CK_KNOWLEDGE_LOCAL_ROOT 仅供测试注入 */ +export function getLocalKnowledgeDir(project: Project): string { + const override = process.env.CK_KNOWLEDGE_LOCAL_ROOT; + const safeName = project.id.replace(/[\\/:*?"<>|]/g, '_'); + const base = override ?? join(getAppStorageDir(), 'memory', 'knowledge'); + return join(base, safeName); +} + +/** 蒸馏草稿箱(共享根下,人审通过后才移入 knowledge/) */ +export function getKnowledgeInboxDir(project: Project): string { + return join(project.rootPath, '.codekeeper', 'knowledge-inbox'); +} + +/** 序列化条目为 Markdown + frontmatter */ +export function serializeKnowledgeItem(item: { + frontmatter: KnowledgeFrontmatter; + body: string; +}): string { + return `---\n${stringify(item.frontmatter)}---\n\n${item.body.trim()}\n`; +} + +/** 解析 Markdown 文件为知识条目;非法时抛 KnowledgeStoreError */ +export function parseKnowledgeItem( + content: string, + root: KnowledgeItem['root'], + filePath: string +): KnowledgeItem { + // Windows 编辑器常带 BOM,先剥离再匹配 frontmatter + const normalized = content.replace(/^\uFEFF/, ''); + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(normalized); + if (!match) { + throw new KnowledgeStoreError(`知识条目缺少 frontmatter: ${filePath}`); + } + let rawFm: unknown; + try { + rawFm = parseYaml(match[1]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new KnowledgeStoreError(`知识条目 frontmatter 非法 YAML: ${filePath}: ${message}`); + } + const result = knowledgeFrontmatterSchema.safeParse(rawFm); + if (!result.success) { + const issues = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; '); + throw new KnowledgeStoreError(`知识条目 frontmatter 不符合 schema: ${filePath}: ${issues}`); + } + return { frontmatter: result.data, body: match[2].trim(), root, filePath }; +} + +/** 读取一个目录下的全部知识条目(非法文件跳过并记录) */ +export function readKnowledgeDir(dir: string, root: KnowledgeItem['root']): KnowledgeItem[] { + if (!existsSync(dir)) return []; + const items: KnowledgeItem[] = []; + for (const entry of readdirSync(dir)) { + if (!entry.endsWith('.md')) continue; + const filePath = join(dir, entry); + try { + items.push(parseKnowledgeItem(readFileSync(filePath, 'utf-8'), root, filePath)); + } catch { + // 非法文件不阻断整体读取;调用方需要严格性时用 parseKnowledgeItem 直读 + } + } + return items; +} + +/** 列出项目的全部策展知识(共享 + 私有) */ +export function listProjectKnowledge(project: Project): KnowledgeItem[] { + return [ + ...readKnowledgeDir(getSharedKnowledgeDir(project), 'shared'), + ...readKnowledgeDir(getLocalKnowledgeDir(project), 'local'), + ]; +} + +/** 写入共享正本(human 编辑 / 蒸馏草稿人审通过后的落点) */ +export function writeSharedKnowledge( + project: Project, + item: { frontmatter: KnowledgeFrontmatter; body: string } +): string { + const dir = getSharedKnowledgeDir(project); + mkdirSync(dir, { recursive: true }); + const filePath = join(dir, `${item.frontmatter.id}.md`); + writeFileSync(filePath, serializeKnowledgeItem(item), 'utf-8'); + return filePath; +} + +/** 写入私有正本 */ +export function writeLocalKnowledge( + project: Project, + item: { frontmatter: KnowledgeFrontmatter; body: string } +): string { + const dir = getLocalKnowledgeDir(project); + mkdirSync(dir, { recursive: true }); + const filePath = join(dir, `${item.frontmatter.id}.md`); + writeFileSync(filePath, serializeKnowledgeItem(item), 'utf-8'); + return filePath; +} diff --git a/src/advance/llm/client.ts b/src/advance/llm/client.ts index 53d7f8c..0c0f462 100755 --- a/src/advance/llm/client.ts +++ b/src/advance/llm/client.ts @@ -1,9 +1,16 @@ import Anthropic from '@anthropic-ai/sdk'; -import { logger } from '../../core/logger'; +import { logger } from '../core/logger'; import { logMemorySnapshot } from '../classic/utils/memory-snapshot.js'; import { extractJsonText } from '../classic/utils/json-extraction.js'; -import type { ToolCall, ToolDefinition, LlmMessage, CompleteWithToolsOptions, CompleteWithToolsResult, ToolResult } from './tool-types.js'; +import type { + ToolCall, + ToolDefinition, + LlmMessage, + CompleteWithToolsOptions, + CompleteWithToolsResult, + ToolResult, +} from './tool-types.js'; /** * 决策工具调用未按约定返回时抛出的异常。 @@ -145,11 +152,13 @@ export class LlmClient { const isRateLimit = message.includes('429') || message.includes('Too many requests'); if (!isRateLimit || attempt === maxRetries) { const prefix = `[LlmClient:${this.provider}]`; - const cleanMessage = message.startsWith(prefix) ? message.slice(prefix.length).trim() : message; + const cleanMessage = message.startsWith(prefix) + ? message.slice(prefix.length).trim() + : message; throw new Error(`${prefix} ${cleanMessage}`); } const delay = Math.min(2000 * 2 ** attempt, 60000); - await new Promise((resolve) => setTimeout(resolve, delay)); + await new Promise(resolve => setTimeout(resolve, delay)); } } throw new Error(`[LlmClient:${this.provider}] 重试后仍失败`); @@ -172,7 +181,7 @@ export class LlmClient { const idx = this.mockCallIndex % this.mock.toolResponses.length; this.mockCallIndex++; const r = this.mock.toolResponses[idx]; - const valid = r.toolCalls.filter((tc) => tools.some((t) => t.name === tc.name)); + const valid = r.toolCalls.filter(tc => tools.some(t => t.name === tc.name)); if (valid.length === 1) { return valid[0]; } @@ -184,21 +193,27 @@ export class LlmClient { if (valid.length === 0) { throw new LlmDecisionError('mock 未返回允许的决策工具', 'no_tool_calls'); } - throw new LlmDecisionError(`mock 返回了 ${valid.length} 个允许工具,期望 1 个`, 'multiple_tool_calls'); + throw new LlmDecisionError( + `mock 返回了 ${valid.length} 个允许工具,期望 1 个`, + 'multiple_tool_calls' + ); } // 支持 mock.response 直接模拟 content 兜底 if (this.mock.response?.trim()) { const fallback = this.tryParseDecisionFromContent(tools, this.mock.response); if (fallback) return fallback; } - throw new LlmDecisionError('mock 模式下 completeDecision 需要配置 mock.toolResponses', 'no_tool_calls'); + throw new LlmDecisionError( + 'mock 模式下 completeDecision 需要配置 mock.toolResponses', + 'no_tool_calls' + ); } - const result = await this.completeWithTools( - [{ role: 'user', content: prompt }], - tools, - { system, toolChoice: { type: 'any' }, maxTokens: this.maxTokens } - ); + const result = await this.completeWithTools([{ role: 'user', content: prompt }], tools, { + system, + toolChoice: { type: 'any' }, + maxTokens: this.maxTokens, + }); // 兜底:部分 OpenAI 兼容端点不严格遵循 tool_choice=required,把 JSON 放到了 content 里 if (result.toolCalls.length === 0 && result.content.trim()) { @@ -210,12 +225,18 @@ export class LlmClient { throw new LlmDecisionError('LLM 未返回任何工具调用', 'no_tool_calls'); } if (result.toolCalls.length > 1) { - throw new LlmDecisionError(`LLM 返回了 ${result.toolCalls.length} 个工具调用,期望 1 个`, 'multiple_tool_calls'); + throw new LlmDecisionError( + `LLM 返回了 ${result.toolCalls.length} 个工具调用,期望 1 个`, + 'multiple_tool_calls' + ); } const toolCall = result.toolCalls[0]; - if (!tools.some((t) => t.name === toolCall.name)) { - throw new LlmDecisionError(`LLM 调用了未允许的工具: ${toolCall.name}`, 'unexpected_tool_name'); + if (!tools.some(t => t.name === toolCall.name)) { + throw new LlmDecisionError( + `LLM 调用了未允许的工具: ${toolCall.name}`, + 'unexpected_tool_name' + ); } if (!toolCall.input || typeof toolCall.input !== 'object' || Array.isArray(toolCall.input)) { throw new LlmDecisionError(`工具 ${toolCall.name} 的输入不是合法对象`, 'invalid_input'); @@ -237,7 +258,7 @@ export class LlmClient { max_tokens: options?.maxTokens ?? this.maxTokens, system: options?.system, messages: this.toAnthropicMessages(messages), - tools: tools.map((t) => ({ + tools: tools.map(t => ({ name: t.name, description: t.description, input_schema: t.input_schema, @@ -247,15 +268,20 @@ export class LlmClient { const content = response.content .filter((c): c is Anthropic.TextBlock => c.type === 'text') - .map((c) => c.text) + .map(c => c.text) .join(''); const toolCalls: ToolCall[] = response.content .filter((c): c is Anthropic.ToolUseBlock => c.type === 'tool_use') - .map((c) => ({ id: c.id, name: c.name, input: c.input as Record })); + .map(c => ({ id: c.id, name: c.name, input: c.input as Record })); - logger.debug({ model: this.model, contentLength: content.length, toolCalls: toolCalls.length }, 'Anthropic tool-use 响应'); - console.log(`[LlmClient] completeWithTools Anthropic content 长度=${content.length}, toolCalls=${toolCalls.length}`); + logger.debug( + { model: this.model, contentLength: content.length, toolCalls: toolCalls.length }, + 'Anthropic tool-use 响应' + ); + console.log( + `[LlmClient] completeWithTools Anthropic content 长度=${content.length}, toolCalls=${toolCalls.length}` + ); return { content, @@ -284,7 +310,7 @@ export class LlmClient { messages: bodyMessages, max_tokens: options?.maxTokens ?? this.maxTokens, stream: false, - tools: tools.map((t) => ({ + tools: tools.map(t => ({ type: 'function', function: { name: t.name, @@ -343,10 +369,17 @@ export class LlmClient { } logger.debug( - { status: response.status, model: this.model, contentLength: content.length, toolCalls: toolCalls.length }, + { + status: response.status, + model: this.model, + contentLength: content.length, + toolCalls: toolCalls.length, + }, 'OpenAI tool-use 响应' ); - console.log(`[LlmClient] completeWithTools OpenAI content 长度=${content.length}, toolCalls=${toolCalls.length}`); + console.log( + `[LlmClient] completeWithTools OpenAI content 长度=${content.length}, toolCalls=${toolCalls.length}` + ); return { content: String(content), @@ -356,12 +389,12 @@ export class LlmClient { } private toAnthropicMessages(messages: LlmMessage[]): Anthropic.MessageParam[] { - return messages.map((m) => { + return messages.map(m => { if (typeof m.content === 'string') { return { role: m.role, content: m.content }; } - const contentBlocks: Anthropic.MessageParam['content'] = m.content.map((part) => { + const contentBlocks: Anthropic.MessageParam['content'] = m.content.map(part => { if ('tool_use_id' in part) { const result = part as ToolResult; return { @@ -383,7 +416,9 @@ export class LlmClient { }); } - private toAnthropicToolChoice(toolChoice: CompleteWithToolsOptions['toolChoice']): Anthropic.ToolChoice { + private toAnthropicToolChoice( + toolChoice: CompleteWithToolsOptions['toolChoice'] + ): Anthropic.ToolChoice { if (!toolChoice) return { type: 'auto' }; switch (toolChoice.type) { case 'any': @@ -397,8 +432,20 @@ export class LlmClient { } } - private toOpenAIMessages(messages: LlmMessage[]): Array<{ role: string; content: string; tool_calls?: unknown[]; tool_call_id?: string; name?: string }> { - const result: Array<{ role: string; content: string; tool_calls?: unknown[]; tool_call_id?: string; name?: string }> = []; + private toOpenAIMessages(messages: LlmMessage[]): Array<{ + role: string; + content: string; + tool_calls?: unknown[]; + tool_call_id?: string; + name?: string; + }> { + const result: Array<{ + role: string; + content: string; + tool_calls?: unknown[]; + tool_call_id?: string; + name?: string; + }> = []; for (const m of messages) { if (typeof m.content === 'string') { @@ -408,12 +455,14 @@ export class LlmClient { if (m.role === 'assistant') { const textParts = m.content - .filter((part): part is { type: 'text'; text: string } => 'type' in part && part.type === 'text') - .map((part) => part.text) + .filter( + (part): part is { type: 'text'; text: string } => 'type' in part && part.type === 'text' + ) + .map(part => part.text) .join('\n'); const toolCalls = m.content .filter((part): part is ToolCall => 'name' in part && 'id' in part) - .map((call) => ({ + .map(call => ({ id: call.id, type: 'function', function: { @@ -431,8 +480,10 @@ export class LlmClient { // user 消息:文本部分合并为一条,工具结果拆分为多条 role=tool 消息 const textParts = m.content - .filter((part): part is { type: 'text'; text: string } => 'type' in part && part.type === 'text') - .map((part) => part.text) + .filter( + (part): part is { type: 'text'; text: string } => 'type' in part && part.type === 'text' + ) + .map(part => part.text) .join('\n'); if (textParts) { result.push({ role: 'user', content: textParts }); @@ -480,10 +531,11 @@ export class LlmClient { } // 多工具场景:尝试识别 { name, input } 格式 const parsedName = parsed.name; - if (typeof parsedName === 'string' && tools.some((t) => t.name === parsedName)) { - const input = parsed.input && typeof parsed.input === 'object' && !Array.isArray(parsed.input) - ? (parsed.input as Record) - : parsed; + if (typeof parsedName === 'string' && tools.some(t => t.name === parsedName)) { + const input = + parsed.input && typeof parsed.input === 'object' && !Array.isArray(parsed.input) + ? (parsed.input as Record) + : parsed; console.log(`[LlmClient] completeDecision 从 content 兜底解析为 ${parsedName}`); return { id: `fallback-${this.fallbackId++}`, @@ -539,15 +591,11 @@ export class LlmClient { }, }; - const result = await this.completeWithTools( - [{ role: 'user', content: prompt }], - [tool], - { - system, - toolChoice: { type: 'tool', name: 'respond_json' }, - maxTokens: this.maxTokens, - } - ); + const result = await this.completeWithTools([{ role: 'user', content: prompt }], [tool], { + system, + toolChoice: { type: 'tool', name: 'respond_json' }, + maxTokens: this.maxTokens, + }); if (result.toolCalls.length > 0) { const input = result.toolCalls[0].input; @@ -652,7 +700,9 @@ export class LlmClient { return this.mock.response ?? ''; } - console.log(`[LlmClient] complete 开始 provider=${this.provider} model=${this.model} promptLength=${prompt.length}`); + console.log( + `[LlmClient] complete 开始 provider=${this.provider} model=${this.model} promptLength=${prompt.length}` + ); const startHeap = process.memoryUsage().heapUsed; let peakGrowth = 0; const interval = setInterval(() => { @@ -674,7 +724,9 @@ export class LlmClient { result = await this.completeAnthropic(prompt, system); } clearInterval(interval); - console.log(`[LlmClient] complete prompt 长度=${prompt.length}, response 长度=${result.length}`); + console.log( + `[LlmClient] complete prompt 长度=${prompt.length}, response 长度=${result.length}` + ); return result; } catch (error) { clearInterval(interval); @@ -682,11 +734,13 @@ export class LlmClient { const isRateLimit = message.includes('429') || message.includes('Too many requests'); if (!isRateLimit || attempt === maxRetries) { const prefix = `[LlmClient:${this.provider}]`; - const cleanMessage = message.startsWith(prefix) ? message.slice(prefix.length).trim() : message; + const cleanMessage = message.startsWith(prefix) + ? message.slice(prefix.length).trim() + : message; throw new Error(`${prefix} ${cleanMessage}`); } const delay = Math.min(2000 * 2 ** attempt, 60000); - await new Promise((resolve) => setTimeout(resolve, delay)); + await new Promise(resolve => setTimeout(resolve, delay)); } } clearInterval(interval); @@ -697,7 +751,7 @@ export class LlmClient { const now = Date.now(); const elapsed = now - this.lastRequestTime; if (elapsed < this.minRequestInterval) { - await new Promise((resolve) => setTimeout(resolve, this.minRequestInterval - elapsed)); + await new Promise(resolve => setTimeout(resolve, this.minRequestInterval - elapsed)); } this.lastRequestTime = Date.now(); } @@ -710,9 +764,7 @@ export class LlmClient { } private defaultModel(): string { - return this.provider === 'openai' - ? 'gpt-4o-mini' - : 'claude-3-5-sonnet-20241022'; + return this.provider === 'openai' ? 'gpt-4o-mini' : 'claude-3-5-sonnet-20241022'; } private async completeAnthropic(prompt: string, system?: string): Promise { @@ -730,7 +782,7 @@ export class LlmClient { const text = response.content .filter((c): c is Anthropic.TextBlock => c.type === 'text') - .map((c) => c.text) + .map(c => c.text) .join('') .trim(); logger.debug({ model: this.model, contentLength: text.length }, 'Anthropic 响应'); @@ -801,7 +853,9 @@ export class LlmClient { responseFormat?: 'json_object' ): Promise { const url = this.baseURL ?? 'https://api.openai.com/v1/chat/completions'; - console.log(`[LlmClient] completeOpenAIStream 请求前 model=${this.model} max_tokens=${this.maxTokens}`); + console.log( + `[LlmClient] completeOpenAIStream 请求前 model=${this.model} max_tokens=${this.maxTokens}` + ); const body: Record = { model: this.model, @@ -823,7 +877,9 @@ export class LlmClient { body: JSON.stringify(body), }); - console.log(`[LlmClient] completeOpenAIStream 响应 status=${response.status} content-length=${response.headers.get('content-length') ?? 'unknown'}`); + console.log( + `[LlmClient] completeOpenAIStream 响应 status=${response.status} content-length=${response.headers.get('content-length') ?? 'unknown'}` + ); if (!response.ok) { const text = await this.readResponseTextWithLimit(response); @@ -852,7 +908,9 @@ export class LlmClient { buffer += decoder.decode(value, { stream: true }); if (buffer.length > maxBufferChars) { reader.cancel().catch(() => undefined); - throw new Error(`SSE buffer 累积 ${buffer.length} 字符,超过 ${maxBufferChars},可能后端返回了非 SSE 大响应`); + throw new Error( + `SSE buffer 累积 ${buffer.length} 字符,超过 ${maxBufferChars},可能后端返回了非 SSE 大响应` + ); } const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; @@ -877,7 +935,9 @@ export class LlmClient { if (text) { if (content.length + text.length > MAX_COMPLETE_RESPONSE_CHARS) { reader.cancel().catch(() => undefined); - throw new Error(`流式响应累计长度 ${content.length + text.length} 超过上限 ${MAX_COMPLETE_RESPONSE_CHARS}`); + throw new Error( + `流式响应累计长度 ${content.length + text.length} 超过上限 ${MAX_COMPLETE_RESPONSE_CHARS}` + ); } content += text; } @@ -890,7 +950,9 @@ export class LlmClient { } } - console.log(`[LlmClient] completeOpenAIStream 读取完成 chunks=${chunkCount} contentLength=${content.length}`); + console.log( + `[LlmClient] completeOpenAIStream 读取完成 chunks=${chunkCount} contentLength=${content.length}` + ); logger.debug( { model: this.model, contentLength: content.length, rawPreview: rawPreview.slice(0, 500) }, 'OpenAI 流式响应' diff --git a/src/advance/llm/prompts/classify-prompt.ts b/src/advance/llm/prompts/classify-prompt.ts index 5630f6a..7b480ba 100755 --- a/src/advance/llm/prompts/classify-prompt.ts +++ b/src/advance/llm/prompts/classify-prompt.ts @@ -1,12 +1,30 @@ /** * 默认分类列表 */ -export const DEFAULT_CATEGORIES = ['memory', 'sync', 'skill', 'review', 'design', 'weekly', 'other']; +export const DEFAULT_CATEGORIES = [ + 'memory', + 'sync', + 'skill', + 'review', + 'design', + 'weekly', + 'other', +]; /** * 默认文档类型列表 */ -export const DEFAULT_DOC_TYPES = ['design', 'spec', 'weekly', 'review', 'note', 'code', 'config', 'snippet', 'other']; +export const DEFAULT_DOC_TYPES = [ + 'design', + 'spec', + 'weekly', + 'review', + 'note', + 'code', + 'config', + 'snippet', + 'other', +]; export interface ClassifyPromptOptions { categories?: string[]; @@ -37,8 +55,12 @@ export function buildClassifyPrompt( contentPreview: string, options: ClassifyPromptOptions = {} ): string { - const categoryList = options.categories?.length ? options.categories.join(', ') : DEFAULT_CATEGORIES.join(', '); - const docTypeList = options.docTypes?.length ? options.docTypes.join(', ') : DEFAULT_DOC_TYPES.join(', '); + const categoryList = options.categories?.length + ? options.categories.join(', ') + : DEFAULT_CATEGORIES.join(', '); + const docTypeList = options.docTypes?.length + ? options.docTypes.join(', ') + : DEFAULT_DOC_TYPES.join(', '); const contentSection = contentPreview ? `\n内容前 1500 字符(供参考):\n${contentPreview.slice(0, 1500)}` @@ -134,7 +156,8 @@ export function parseClassifyResponse(text: string): ParsedClassifyResponse | nu ? parsed.sections .filter( (s: unknown) => - s && typeof (s as Record).heading === 'string' && + s && + typeof (s as Record).heading === 'string' && typeof (s as Record).summary === 'string' ) .map((s: Record) => ({ diff --git a/src/advance/llm/prompts/loader.ts b/src/advance/llm/prompts/loader.ts index 223bb92..995cb5b 100755 --- a/src/advance/llm/prompts/loader.ts +++ b/src/advance/llm/prompts/loader.ts @@ -63,11 +63,7 @@ export class FilePromptLoader implements PromptLoader { return join(dirname(__dirname), '..', '..', 'assets', 'prompts'); } - private resolve( - raw: string, - variables: Record, - resolving: Set - ): string { + private resolve(raw: string, variables: Record, resolving: Set): string { // 先处理 include,再处理变量,避免变量值里出现 include 被误解析 let result = this.resolveIncludes(raw, variables, resolving); result = this.substituteVariables(result, variables); @@ -83,7 +79,9 @@ export class FilePromptLoader implements PromptLoader { return raw.replace(includePattern, (_match, includeName: string) => { const normalized = this.normalizeName(includeName.trim()); if (resolving.has(normalized)) { - throw new Error(`Prompt include 循环引用: ${Array.from(resolving).join(' -> ')} -> ${normalized}`); + throw new Error( + `Prompt include 循环引用: ${Array.from(resolving).join(' -> ')} -> ${normalized}` + ); } const content = this.registry.get(normalized) ?? this.readFromDisk(normalized); const nextResolving = new Set(resolving); diff --git a/src/advance/llm/tool-types.ts b/src/advance/llm/tool-types.ts index e897158..e8ec251 100755 --- a/src/advance/llm/tool-types.ts +++ b/src/advance/llm/tool-types.ts @@ -39,10 +39,7 @@ export interface ToolResult { export type LlmMessageRole = 'user' | 'assistant'; -export type LlmMessageContent = - | { type: 'text'; text: string } - | ToolCall - | ToolResult; +export type LlmMessageContent = { type: 'text'; text: string } | ToolCall | ToolResult; export interface LlmMessage { role: LlmMessageRole; diff --git a/src/advance/pipeline/archive-pipeline.ts b/src/advance/pipeline/archive-pipeline.ts index 3f67160..ed31dce 100755 --- a/src/advance/pipeline/archive-pipeline.ts +++ b/src/advance/pipeline/archive-pipeline.ts @@ -15,7 +15,7 @@ import type { MetadataStore } from '../store/metadata-store'; import type { Project, ArchiveAction, ClassificationResult } from '../types'; import { getArchiveRoot } from '../types'; import { loadProjectConfig, matchesProjectPathPatterns } from '../config/project-config'; -import { logger } from '../../core/logger'; +import { logger } from '../core/logger'; export interface ArchivePipelineOptions { store: MetadataStore; @@ -41,7 +41,10 @@ export class ArchivePipeline { async run(project: Project): Promise { const events = this.options.store.listPendingEvents(this.options.maxEvents); - logger.info({ projectId: project.id, projectRoot: project.rootPath, eventCount: events.length }, '开始归档扫描'); + logger.info( + { projectId: project.id, projectRoot: project.rootPath, eventCount: events.length }, + '开始归档扫描' + ); if (events.length === 0) { logger.info('没有待处理的文件事件,跳过本次扫描'); await this.reconcileArchived(project); @@ -62,8 +65,8 @@ export class ArchivePipeline { const executor = new ArchiveExecutor({ archiveRoot }); const existingMetadata = this.options.store.listArchiveMetadataByProject(project.id); - const existingBySource = new Map(existingMetadata.map((m) => [m.sourcePath, m])); - const existingArchivePaths = new Set(existingMetadata.map((m) => m.archivePath.toLowerCase())); + const existingBySource = new Map(existingMetadata.map(m => [m.sourcePath, m])); + const existingArchivePaths = new Set(existingMetadata.map(m => m.archivePath.toLowerCase())); const processedEventIds: number[] = []; const executedIds: string[] = []; @@ -145,7 +148,8 @@ export class ArchivePipeline { copiedAt: existing?.copiedAt ?? now, organizedAt: action.type === 'organize' ? now : (existing?.organizedAt ?? undefined), status: 'active', - type: action.type === 'organize' ? 'organize' : action.type === 'flag' ? 'flag' : 'copy', + type: + action.type === 'organize' ? 'organize' : action.type === 'flag' ? 'flag' : 'copy', }); existingArchivePaths.add(finalArchivePath.toLowerCase()); } @@ -177,7 +181,10 @@ export class ArchivePipeline { private async handleUnlink( project: Project, filePath: string, - existingBySource: Map[number]>, + existingBySource: Map< + string, + ReturnType[number] + >, now: number ): Promise { const entryId = makeEntryId(project.id, filePath); @@ -274,9 +281,7 @@ export class ArchivePipeline { }); } - private buildContextEntries( - projectId: string - ): Array<{ + private buildContextEntries(projectId: string): Array<{ filePath: string; archivePath: string; category: string; @@ -288,7 +293,7 @@ export class ArchivePipeline { updatedAt: number; }> { const all = this.options.store.listArchiveMetadataByProject(projectId); - return all.map((m) => ({ + return all.map(m => ({ filePath: m.sourcePath, archivePath: m.archivePath, category: m.category, @@ -296,7 +301,8 @@ export class ArchivePipeline { summary: m.summary, tags: m.tags, sections: [], - status: m.status === 'active' ? 'archived' : m.status === 'superseded' ? 'archived' : m.status, + status: + m.status === 'active' ? 'archived' : m.status === 'superseded' ? 'archived' : m.status, updatedAt: m.organizedAt ?? m.copiedAt, })); } @@ -346,10 +352,7 @@ export class ArchivePipeline { writeReadme({ archiveRoot }); - logger.info( - { projectId: project.id, scanStatus: status.scanStatus, counts }, - '归档扫描完成' - ); + logger.info({ projectId: project.id, scanStatus: status.scanStatus, counts }, '归档扫描完成'); } } diff --git a/src/advance/pipeline/core/executor.ts b/src/advance/pipeline/core/executor.ts new file mode 100644 index 0000000..0b58247 --- /dev/null +++ b/src/advance/pipeline/core/executor.ts @@ -0,0 +1,340 @@ +/** + * 管线顺序执行器。 + * + * v1 语义: + * - 按拓扑序在进程内顺序执行节点;节点间产物经边(from.port -> to.port)传递 + * - 每个节点边界落库(PipelineRunStore),失败即停,可通过 resume 从最近成功点续跑 + * - channel 绑定当前仅作元数据记录;queue/gitlab-discussion/everos/a2a-task 等 + * 非 memory 通道的实体化在后续里程碑实现 + * - NodeDef.subgraph(钻取层)在 M7 才解释,当前忽略 + */ + +import type { NodeDef, NodeHandler, PipelineDefinition, RunContext } from './types.js'; +import { PipelineDefinitionError, type NodeWithSubgraph } from './types.js'; +import { topoSort, validateGraph } from './topology.js'; +import type { PipelineRunRecord, PipelineRunStore } from './run-store.js'; + +export interface ExecuteOptions { + /** 运行关联的项目 id(可选,写入 run 记录) */ + projectId?: string; + /** + * 只执行从给定节点出发可达的子 DAG(含起点本身)。 + * 用于多触发器管线:某个 trigger 触发时只运行其下游分支。 + * 缺省执行整张图。 + */ + startFrom?: string[]; +} + +export class PipelineExecutor { + constructor( + private readonly handlers: ReadonlyMap, + private readonly store?: PipelineRunStore + ) {} + + /** 从头执行一条管线;节点失败时运行标记为 failed 并返回(不抛异常)。 */ + async execute( + definition: PipelineDefinition, + ctx: RunContext, + options: ExecuteOptions = {} + ): Promise { + const ordered = this.prepare(definition, options.startFrom); + const runId = + this.store?.createRun(definition.id, definition, options.projectId) ?? 'ephemeral'; + ctx.vars = { ...ctx.vars, pipelineId: definition.id, runId }; + + const status = await this.executeNodes(definition, ordered, ctx, runId, new Map(), new Set()); + if (!this.store) { + return ephemeralRecord(runId, definition, status.error, options.projectId); + } + this.store.finishRun( + runId, + status.error ? (status.cancelled ? 'cancelled' : 'failed') : 'succeeded', + status.error + ); + const finished = this.store.getRun(runId); + if (!finished) { + throw new PipelineDefinitionError(`运行记录写入后不可读: ${runId}`); + } + return finished; + } + + /** + * 从最近成功节点续跑一个 failed/running 的 run。 + * 已成功节点不重跑,其落库产物继续供下游消费。 + * 注意: + * - 续跑使用的是 run 创建时落库的定义快照,对 pipeline.yaml 的后续修改不影响进行中的 run; + * - resume 按全图校验处理器,若原 run 是 startFrom 子图执行且其它分支处理器未注册, + * 需由调用方保证注册表覆盖全图(当前调度器不使用 resume,预留给后续里程碑)。 + */ + async resume(runId: string, ctx: RunContext): Promise { + if (!this.store) { + throw new PipelineDefinitionError('无 PipelineRunStore,无法 resume'); + } + const record = this.store.getRun(runId); + if (!record) { + throw new PipelineDefinitionError(`运行记录不存在: ${runId}`); + } + if (record.status === 'succeeded') { + return record; + } + + const definition = record.definition; + // 子图的 stage 记录键为 parent/child 形式,resume 跳过集暂不支持; + // 显式拒绝,待支持后再放开 + const hasSubgraph = definition.nodes.some(node => (node as { subgraph?: unknown }).subgraph); + if (hasSubgraph) { + throw new PipelineDefinitionError('暂不支持对含子图的 run 执行 resume'); + } + const ordered = this.prepare(definition); + ctx.vars = { ...ctx.vars, pipelineId: definition.id, runId }; + + const completedOutputs = this.store.getSucceededOutputs(runId); + const status = await this.executeNodes( + definition, + ordered, + ctx, + runId, + completedOutputs, + new Set(completedOutputs.keys()) + ); + this.store.finishRun( + runId, + status.error ? (status.cancelled ? 'cancelled' : 'failed') : 'succeeded', + status.error + ); + const resumed = this.store.getRun(runId); + if (!resumed) { + throw new PipelineDefinitionError(`运行记录写入后不可读: ${runId}`); + } + return resumed; + } + + /** 结构校验 + 处理器存在性/端口声明校验 + 拓扑排序(startFrom 时裁剪为下游子图) */ + private prepare(definition: PipelineDefinition, startFrom?: string[]): NodeDef[] { + validateGraph(definition); + // startFrom 时先裁剪:只校验并执行可达子图,允许图中存在本次不执行的、 + // 处理器未注册的其它分支(如多角色管线中别的角色分支) + const ordered = topoSort(definition); + const scoped = startFrom + ? ordered.filter(node => collectReachable(definition, startFrom).has(node.id)) + : ordered; + + const issues: string[] = []; + for (const node of scoped) { + const handler = this.handlers.get(node.type); + if (!handler) { + // 带 subgraph 的节点无处理器也可执行(递归子图),其余必须注册 + if (!(node as NodeWithSubgraph).subgraph) { + issues.push(`节点 ${node.id} 的类型未注册处理器: ${node.type}`); + } + continue; + } + if (handler.inputs || handler.outputs) { + for (const edge of definition.edges) { + if ( + edge.to.node === node.id && + handler.inputs && + !handler.inputs.includes(edge.to.port) + ) { + issues.push(`节点 ${node.id} 未声明输入端口: ${edge.to.port}`); + } + if ( + edge.from.node === node.id && + handler.outputs && + !handler.outputs.includes(edge.from.port) + ) { + issues.push(`节点 ${node.id} 未声明输出端口: ${edge.from.port}`); + } + } + } + } + if (issues.length > 0) { + throw new PipelineDefinitionError('管线处理器校验失败', issues); + } + return scoped; + } + + private async executeNodes( + definition: PipelineDefinition, + ordered: NodeDef[], + ctx: RunContext, + runId: string, + completedOutputs: Map>, + skip: Set + ): Promise<{ error?: string; cancelled?: boolean }> { + for (const node of ordered) { + if (skip.has(node.id)) continue; + if (ctx.signal?.aborted) { + return { error: '运行被取消', cancelled: true }; + } + + const handler = this.handlers.get(node.type); + const subgraph = (node as NodeWithSubgraph).subgraph; + if (!handler && !subgraph) { + // prepare() 已校验,理论不可达;防御性处理 + return { error: `节点 ${node.id} 的类型未注册处理器: ${node.type}` }; + } + const inputs = this.gatherInputs(definition, node.id, completedOutputs); + const stageId = this.store?.beginStage(runId, node.id, inputs); + + try { + // 带 subgraph 且未注册处理器的节点:递归执行子图(钻取层), + // 子图终态节点的输出汇聚为父节点产物;stage 记录以 parent/child 命名 + let outputs: Record; + if (subgraph && !handler) { + outputs = await this.executeSubgraph(node as NodeWithSubgraph, inputs, ctx, runId); + } else if (handler) { + outputs = (await handler.run(ctx, inputs, node.params, node)) ?? {}; + } else { + outputs = {}; // prepare/上方防御已拦截,理论不可达 + } + completedOutputs.set(node.id, outputs); + if (this.store && stageId) { + this.store.finishStage(stageId, 'succeeded', outputs); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (this.store && stageId) { + this.store.finishStage(stageId, 'failed', undefined, message); + } + ctx.logger.error(`节点 ${node.id} 执行失败: ${message}`); + return { error: `节点 ${node.id} 执行失败: ${message}` }; + } + } + return {}; + } + + /** + * 递归执行节点子图(钻取层)。 + * 入口节点(无入边)按端口名接收父节点输入;终态节点(无出边)的输出合并为父节点输出。 + * 子图节点的 stage 记录以 `${parentId}/${childId}` 命名,可在运行观测中区分层级。 + */ + private async executeSubgraph( + parent: NodeWithSubgraph, + parentInputs: Record, + ctx: RunContext, + runId: string + ): Promise> { + const sub = parent.subgraph; + if (!sub) throw new PipelineDefinitionError(`节点 ${parent.id} 缺少子图`); + validateGraph(sub); + const ordered = topoSort(sub); + for (const node of ordered) { + // 子图内同样支持"带子图的复合节点"(任意深度嵌套),否则必须注册处理器 + if (!this.handlers.has(node.type) && !(node as NodeWithSubgraph).subgraph) { + throw new PipelineDefinitionError( + `子图 ${parent.id} 中节点 ${node.id} 的类型未注册处理器: ${node.type}` + ); + } + } + + const subOutputs = new Map>(); + const incomingTargets = new Set(sub.edges.map(edge => edge.to.node)); + const outgoingSources = new Set(sub.edges.map(edge => edge.from.node)); + + for (const node of ordered) { + if (ctx.signal?.aborted) { + throw new Error('运行被取消'); + } + const handler = this.handlers.get(node.type); + const nestedSubgraph = (node as NodeWithSubgraph).subgraph; + if (!handler && !nestedSubgraph) { + throw new PipelineDefinitionError(`子图节点 ${node.id} 的类型未注册处理器: ${node.type}`); + } + // 入口节点(无子图内入边)按端口名继承父节点输入 + const inputs = { + ...(incomingTargets.has(node.id) ? {} : parentInputs), + ...this.gatherInputs(sub, node.id, subOutputs), + }; + const stageKey = `${parent.id}/${node.id}`; + const stageId = this.store?.beginStage(runId, stageKey, inputs); + try { + // 任意深度嵌套:带子图且无处理器的节点递归执行 + const outputs = + nestedSubgraph && !handler + ? await this.executeSubgraph(node as NodeWithSubgraph, inputs, ctx, runId) + : ((await handler?.run(ctx, inputs, node.params, node)) ?? {}); + subOutputs.set(node.id, outputs); + if (this.store && stageId) { + this.store.finishStage(stageId, 'succeeded', outputs); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (this.store && stageId) { + this.store.finishStage(stageId, 'failed', undefined, message); + } + throw new Error(`子图 ${parent.id} 节点 ${node.id} 执行失败: ${message}`); + } + } + + // 终态节点(无子图内出边)的输出合并为父节点输出 + const merged: Record = {}; + for (const node of ordered) { + if (outgoingSources.has(node.id)) continue; + Object.assign(merged, subOutputs.get(node.id) ?? {}); + } + return merged; + } + + /** 汇聚某节点的全部入边产物:inputs[to.port] = 上游 outputs[from.port] */ + private gatherInputs( + definition: PipelineDefinition, + nodeId: string, + completedOutputs: Map> + ): Record { + const inputs: Record = {}; + for (const edge of definition.edges) { + if (edge.to.node !== nodeId) continue; + const upstream = completedOutputs.get(edge.from.node); + if (upstream && edge.from.port in upstream) { + inputs[edge.to.port] = upstream[edge.from.port]; + } + } + return inputs; + } +} + +/** 收集从起点集合出发可达的全部节点 id(含起点);起点不存在时抛错。 */ +function collectReachable(definition: PipelineDefinition, startFrom: string[]): Set { + const known = new Set(definition.nodes.map(node => node.id)); + const downstream = new Map(); + for (const edge of definition.edges) { + downstream.set(edge.from.node, [...(downstream.get(edge.from.node) ?? []), edge.to.node]); + } + + const reachable = new Set(); + const queue: string[] = []; + for (const id of startFrom) { + if (!known.has(id)) { + throw new PipelineDefinitionError(`startFrom 起点节点不存在: ${id}`); + } + queue.push(id); + } + while (queue.length > 0) { + const id = queue.shift(); + if (id === undefined || reachable.has(id)) continue; + reachable.add(id); + queue.push(...(downstream.get(id) ?? [])); + } + return reachable; +} + +/** 无 store 时的临时运行记录(不持久化,仅返回执行结果) */ +function ephemeralRecord( + runId: string, + definition: PipelineDefinition, + error: string | undefined, + projectId?: string +): PipelineRunRecord { + const now = Date.now(); + return { + id: runId, + pipelineId: definition.id, + projectId: projectId ?? null, + status: error ? 'failed' : 'succeeded', + definition, + error: error ?? null, + createdAt: now, + finishedAt: now, + }; +} diff --git a/src/advance/pipeline/core/loader.ts b/src/advance/pipeline/core/loader.ts new file mode 100644 index 0000000..4fcc0ad --- /dev/null +++ b/src/advance/pipeline/core/loader.ts @@ -0,0 +1,46 @@ +/** + * 管线定义 YAML 加载器。 + * + * 正本位置:/.codekeeper/pipeline.yaml + * 人类可直接编辑该文件;画布编辑器读写同一文件。 + */ + +import { readFileSync } from 'node:fs'; +import { parse as parseYaml } from 'yaml'; +import { pipelineWithSubgraphSchema, type PipelineDefinitionWithSubgraph } from './types.js'; +import { PipelineDefinitionError } from './types.js'; + +/** 项目内管线定义正本的相对路径 */ +export const PIPELINE_DEFINITION_RELATIVE_PATH = '.codekeeper/pipeline.yaml'; + +/** 解析 YAML 文本为管线定义;校验失败抛出 PipelineDefinitionError(聚合全部 issue)。 */ +export function parsePipelineDefinition(text: string): PipelineDefinitionWithSubgraph { + let raw: unknown; + try { + raw = parseYaml(text); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new PipelineDefinitionError('管线定义 YAML 解析失败', [message]); + } + + const result = pipelineWithSubgraphSchema.safeParse(raw); + if (!result.success) { + const issues = result.error.issues.map( + issue => `${issue.path.join('.') || '(root)'}: ${issue.message}` + ); + throw new PipelineDefinitionError('管线定义不符合 schema', issues); + } + return result.data; +} + +/** 从文件加载管线定义。文件不存在抛出 PipelineDefinitionError。 */ +export function loadPipelineDefinition(filePath: string): PipelineDefinitionWithSubgraph { + let text: string; + try { + text = readFileSync(filePath, 'utf-8'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new PipelineDefinitionError(`管线定义文件不可读: ${filePath}`, [message]); + } + return parsePipelineDefinition(text); +} diff --git a/src/advance/pipeline/core/run-store.ts b/src/advance/pipeline/core/run-store.ts new file mode 100644 index 0000000..e026cc3 --- /dev/null +++ b/src/advance/pipeline/core/run-store.ts @@ -0,0 +1,199 @@ +/** + * 管线运行持久化:pipeline_runs / stage_runs 的 CRUD。 + * + * 每个节点边界落库是"持久化执行"的基础:一条管线可能跨天等待外部事件, + * 进程重启后可从最近成功的节点继续(见 executor.resume)。 + */ + +import { randomUUID } from 'node:crypto'; +import type Database from 'better-sqlite3'; +import type { PipelineDefinition } from './types.js'; + +export type PipelineRunStatus = 'running' | 'succeeded' | 'failed' | 'cancelled'; +export type StageRunStatus = 'running' | 'succeeded' | 'failed' | 'skipped'; + +export interface PipelineRunRecord { + id: string; + pipelineId: string; + projectId: string | null; + status: PipelineRunStatus; + definition: PipelineDefinition; + error: string | null; + createdAt: number; + finishedAt: number | null; +} + +export interface StageRunRecord { + id: string; + runId: string; + nodeId: string; + status: StageRunStatus; + inputs: Record | null; + outputs: Record | null; + error: string | null; + startedAt: number; + finishedAt: number | null; +} + +interface RunRow { + id: string; + pipeline_id: string; + project_id: string | null; + status: PipelineRunStatus; + definition_json: string; + error: string | null; + created_at: number; + finished_at: number | null; +} + +interface StageRow { + id: string; + run_id: string; + node_id: string; + status: StageRunStatus; + inputs_json: string | null; + outputs_json: string | null; + error: string | null; + started_at: number; + finished_at: number | null; +} + +export class PipelineRunStore { + constructor(private readonly db: Database.Database) {} + + createRun(pipelineId: string, definition: PipelineDefinition, projectId?: string): string { + const id = randomUUID(); + this.db + .prepare( + `INSERT INTO pipeline_runs (id, pipeline_id, project_id, status, definition_json, created_at) + VALUES (?, ?, ?, 'running', ?, ?)` + ) + .run(id, pipelineId, projectId ?? null, JSON.stringify(definition), Date.now()); + return id; + } + + finishRun(runId: string, status: Exclude, error?: string): void { + this.db + .prepare('UPDATE pipeline_runs SET status = ?, error = ?, finished_at = ? WHERE id = ?') + .run(status, error ?? null, Date.now(), runId); + } + + getRun(runId: string): PipelineRunRecord | null { + const row = this.db.prepare('SELECT * FROM pipeline_runs WHERE id = ?').get(runId) as + | RunRow + | undefined; + return row ? toRunRecord(row) : null; + } + + listRuns(pipelineId?: string, limit = 50): PipelineRunRecord[] { + const rows = ( + pipelineId + ? this.db + .prepare( + 'SELECT * FROM pipeline_runs WHERE pipeline_id = ? ORDER BY created_at DESC LIMIT ?' + ) + .all(pipelineId, limit) + : this.db.prepare('SELECT * FROM pipeline_runs ORDER BY created_at DESC LIMIT ?').all(limit) + ) as RunRow[]; + return rows.map(toRunRecord); + } + + /** 按项目过滤(SQL 层),画布运行状态叠加用 */ + listRunsByProject(projectId: string, limit = 20): PipelineRunRecord[] { + const rows = this.db + .prepare('SELECT * FROM pipeline_runs WHERE project_id = ? ORDER BY created_at DESC LIMIT ?') + .all(projectId, limit) as RunRow[]; + return rows.map(toRunRecord); + } + + /** + * 节点开始执行:新建或重置该节点的 stage 记录(resume 重跑时复用同一行)。 + * 注意:仅 executor 在确认节点未成功时才应调用;重置会抹掉上一次尝试的 + * inputs/outputs/error,不保留 attempt 历史。 + */ + beginStage(runId: string, nodeId: string, inputs: Record): string { + const existing = this.db + .prepare('SELECT id FROM stage_runs WHERE run_id = ? AND node_id = ?') + .get(runId, nodeId) as { id: string } | undefined; + if (existing) { + this.db + .prepare( + `UPDATE stage_runs + SET status = 'running', inputs_json = ?, outputs_json = NULL, error = NULL, + started_at = ?, finished_at = NULL + WHERE id = ?` + ) + .run(JSON.stringify(inputs), Date.now(), existing.id); + return existing.id; + } + const id = randomUUID(); + this.db + .prepare( + `INSERT INTO stage_runs (id, run_id, node_id, status, inputs_json, started_at) + VALUES (?, ?, ?, 'running', ?, ?)` + ) + .run(id, runId, nodeId, JSON.stringify(inputs), Date.now()); + return id; + } + + finishStage( + stageId: string, + status: Exclude, + outputs?: Record, + error?: string + ): void { + this.db + .prepare( + 'UPDATE stage_runs SET status = ?, outputs_json = ?, error = ?, finished_at = ? WHERE id = ?' + ) + .run(status, outputs ? JSON.stringify(outputs) : null, error ?? null, Date.now(), stageId); + } + + getStageRuns(runId: string): StageRunRecord[] { + const rows = this.db + .prepare('SELECT * FROM stage_runs WHERE run_id = ? ORDER BY started_at ASC, rowid ASC') + .all(runId) as StageRow[]; + return rows.map(toStageRecord); + } + + /** resume 用:已成功节点的输出产物表(nodeId -> outputs) */ + getSucceededOutputs(runId: string): Map> { + const rows = this.db + .prepare( + "SELECT node_id, outputs_json FROM stage_runs WHERE run_id = ? AND status = 'succeeded'" + ) + .all(runId) as Array<{ node_id: string; outputs_json: string | null }>; + const map = new Map>(); + for (const row of rows) { + map.set(row.node_id, row.outputs_json ? JSON.parse(row.outputs_json) : {}); + } + return map; + } +} + +function toRunRecord(row: RunRow): PipelineRunRecord { + return { + id: row.id, + pipelineId: row.pipeline_id, + projectId: row.project_id, + status: row.status, + definition: JSON.parse(row.definition_json) as PipelineDefinition, + error: row.error, + createdAt: row.created_at, + finishedAt: row.finished_at, + }; +} + +function toStageRecord(row: StageRow): StageRunRecord { + return { + id: row.id, + runId: row.run_id, + nodeId: row.node_id, + status: row.status, + inputs: row.inputs_json ? JSON.parse(row.inputs_json) : null, + outputs: row.outputs_json ? JSON.parse(row.outputs_json) : null, + error: row.error, + startedAt: row.started_at, + finishedAt: row.finished_at, + }; +} diff --git a/src/advance/pipeline/core/topology.ts b/src/advance/pipeline/core/topology.ts new file mode 100644 index 0000000..0b82449 --- /dev/null +++ b/src/advance/pipeline/core/topology.ts @@ -0,0 +1,81 @@ +/** + * 管线图拓扑工具:校验与拓扑排序。 + */ + +import type { EdgeDef, NodeDef, PipelineDefinition } from './types.js'; +import { PipelineCycleError, PipelineDefinitionError } from './types.js'; + +/** + * 校验图结构:节点 id 唯一、边的端点引用存在的节点。 + * 抛出 PipelineDefinitionError(聚合全部问题)。 + */ +export function validateGraph(def: PipelineDefinition): void { + const issues: string[] = []; + const nodeIds = new Set(); + + for (const node of def.nodes) { + if (nodeIds.has(node.id)) { + issues.push(`节点 id 重复: ${node.id}`); + } + nodeIds.add(node.id); + } + + for (const edge of def.edges) { + if (!nodeIds.has(edge.from.node)) { + issues.push(`边 ${describeEdge(edge)} 的起点节点不存在: ${edge.from.node}`); + } + if (!nodeIds.has(edge.to.node)) { + issues.push(`边 ${describeEdge(edge)} 的终点节点不存在: ${edge.to.node}`); + } + } + + if (issues.length > 0) { + throw new PipelineDefinitionError('管线定义校验失败', issues); + } +} + +/** 拓扑排序(Kahn)。有环时抛出 PipelineCycleError。 */ +export function topoSort(def: PipelineDefinition): NodeDef[] { + validateGraph(def); + + const indegree = new Map(); + const downstream = new Map(); + for (const node of def.nodes) { + indegree.set(node.id, 0); + } + for (const edge of def.edges) { + if (edge.from.node === edge.to.node) { + throw new PipelineCycleError([edge.from.node]); + } + indegree.set(edge.to.node, (indegree.get(edge.to.node) ?? 0) + 1); + downstream.set(edge.from.node, [...(downstream.get(edge.from.node) ?? []), edge.to.node]); + } + + const queue = def.nodes.filter(node => (indegree.get(node.id) ?? 0) === 0).map(node => node.id); + const ordered: NodeDef[] = []; + const byId = new Map(def.nodes.map(node => [node.id, node])); + + while (queue.length > 0) { + const id = queue.shift(); + if (id === undefined) break; + const node = byId.get(id); + if (!node) continue; + ordered.push(node); + for (const next of downstream.get(id) ?? []) { + const remaining = (indegree.get(next) ?? 0) - 1; + indegree.set(next, remaining); + if (remaining === 0) queue.push(next); + } + } + + if (ordered.length !== def.nodes.length) { + const inCycle = def.nodes.filter(node => (indegree.get(node.id) ?? 0) > 0).map(node => node.id); + throw new PipelineCycleError(inCycle); + } + + return ordered; +} + +function describeEdge(edge: EdgeDef): string { + return edge.id ?? `${edge.from.node}.${edge.from.port} -> ${edge.to.node}.${edge.to.port}`; +} diff --git a/src/advance/pipeline/core/types.ts b/src/advance/pipeline/core/types.ts new file mode 100644 index 0000000..91eae66 --- /dev/null +++ b/src/advance/pipeline/core/types.ts @@ -0,0 +1,146 @@ +/** + * 管线图核心类型与 Schema + * + * 管线定义的正本是项目内的 `.codekeeper/pipeline.yaml`(人类可读、可入库), + * 画布编辑器与 daemon 均以本模块的 zod schema 为准进行校验。 + * + * 分层模型: + * - 画布层节点:trigger.* / role.* / agent.* / knowledge.* / sink.* + * - 钻取层:NodeDef.subgraph 预留(M7),允许 Role 节点内展开 stage 子图 + * - 边 = 类型化端口引用 + channel 绑定(人类参与点如 gitlab-discussion 显式可见) + */ + +import { z } from 'zod'; + +/** 端口引用:某节点的某个端口 */ +export const portRefSchema = z.object({ + node: z.string().min(1), + port: z.string().min(1), +}); +export type PortRef = z.infer; + +/** + * 边的传输通道绑定。 + * - memory:进程内直接传递(默认) + * - queue:经 SQLite 队列异步传递 + * - gitlab-discussion:经 GitLab discussion(人类参与点) + * - everos:经 EverOS 记忆 + * - fs:经文件系统 + * - a2a-task:经 A2A 任务(外部 Agent) + */ +export const edgeChannelSchema = z.enum([ + 'memory', + 'queue', + 'gitlab-discussion', + 'everos', + 'fs', + 'a2a-task', +]); +export type EdgeChannel = z.infer; + +export const edgeSchema = z.object({ + id: z.string().optional(), + from: portRefSchema, + to: portRefSchema, + channel: edgeChannelSchema.default('memory'), + /** 边上流动的产物类型(如 MRContext / Findings / KnowledgeItem),用于校验与展示 */ + artifactType: z.string().optional(), +}); +export type EdgeDef = z.infer; + +export const nodeSchema = z.object({ + id: z.string().min(1), + /** 节点类型,命名空间式:trigger.cron / role.reviewer / agent.a2a / knowledge.recall / sink.gitlab … */ + type: z.string().min(1), + label: z.string().optional(), + /** + * 节点参数(角色配置、触发器 cron 表达式等),由节点处理器解释。 + * 注意:params 会随运行记录明文落库,凭据(token/apiKey)禁止写入 params + * 或产物,只允许经 RunContext.services 注入。 + */ + params: z.record(z.unknown()).default({}), + /** 画布坐标(可视化编辑器写回) */ + position: z.object({ x: z.number(), y: z.number() }).optional(), +}); +export type NodeDef = z.infer; + +export const pipelineDefinitionSchema = z.object({ + version: z.literal(1), + id: z.string().min(1), + label: z.string().optional(), + nodes: z.array(nodeSchema).min(1), + edges: z.array(edgeSchema).default([]), +}); +export type PipelineDefinition = z.infer; + +/** + * 带钻取层预留的节点视图。 + * subgraph 在 schema 层面以宽松递归形式预留(M7 才由执行器解释)。 + * 递归部分用 ZodTypeAny 标注以避免 zod 输入/输出类型分裂,精度由下方手写类型提供。 + */ +export const nodeWithSubgraphSchema = nodeSchema.extend({ + subgraph: z.lazy((): z.ZodTypeAny => pipelineWithSubgraphSchema).optional(), +}); +export type NodeWithSubgraph = z.infer & { + subgraph?: PipelineDefinitionWithSubgraph; +}; + +export const pipelineWithSubgraphSchema = pipelineDefinitionSchema.extend({ + nodes: z.array(nodeWithSubgraphSchema).min(1), +}); +export type PipelineDefinitionWithSubgraph = Omit & { + nodes: NodeWithSubgraph[]; +}; + +/** 节点处理器:执行一个节点并产出输出端口产物 */ +export interface NodeHandler { + /** 处理的节点类型(与 NodeDef.type 对应) */ + readonly type: string; + /** 声明的输入端口名(可选,用于校验边连接) */ + readonly inputs?: readonly string[]; + /** 声明的输出端口名(可选,用于校验边连接与产物类型) */ + readonly outputs?: readonly string[]; + /** + * 同类型多节点共享一份处理器时,经第四个参数区分当前执行节点 + * (信封 id、日志定位等需要节点粒度的场景必须用它,勿闭包捕获)。 + */ + run( + ctx: RunContext, + inputs: Record, + params: Record, + node: NodeDef + ): Promise | void>; +} + +/** 节点执行上下文;服务(llm/memory/store 等)由 daemon 在装配时注入 services */ +export interface RunContext { + /** 结构化日志(与 core/logger 兼容的最小接口) */ + logger: { + info: (msg: string) => void; + warn: (msg: string) => void; + error: (msg: string) => void; + }; + /** 节点间取消信号 */ + signal?: AbortSignal; + /** 装配期注入的服务表(llm、memory、gitProvider 等),M3 起由 daemon 填充 */ + services: Record; + /** 运行级变量(如 projectId、pipelineId、runId) */ + vars: Record; +} + +export class PipelineDefinitionError extends Error { + constructor( + message: string, + readonly issues: string[] = [] + ) { + super(issues.length > 0 ? `${message}\n${issues.join('\n')}` : message); + this.name = 'PipelineDefinitionError'; + } +} + +export class PipelineCycleError extends Error { + constructor(readonly cycleNodeIds: string[]) { + super(`管线定义存在环,涉及节点: ${cycleNodeIds.join(', ')}`); + this.name = 'PipelineCycleError'; + } +} diff --git a/src/advance/pipeline/default-pipeline.ts b/src/advance/pipeline/default-pipeline.ts new file mode 100644 index 0000000..ee15e6c --- /dev/null +++ b/src/advance/pipeline/default-pipeline.ts @@ -0,0 +1,152 @@ +/** + * 默认管线生成与自动迁移。 + * + * 老配置(项目 roles_config 中的角色开关与调度)在首次加载时自动生成 + * `.codekeeper/pipeline.yaml` 正本:每个启用角色 = trigger.cron 节点 → role.* 节点。 + * 生成后人类可直接编辑 YAML,画布编辑器读写同一文件。 + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { stringify } from 'yaml'; +import { logger } from '../core/logger.js'; +import { recordProjectError } from '../classic/status/project-status-store.js'; +import type { Project, Role, RoleConfig } from '../types.js'; +import { getRoleConfigSchedule, isRoleConfigEnabled, ROLES } from '../types.js'; +import { PIPELINE_DEFINITION_RELATIVE_PATH, parsePipelineDefinition } from './core/loader.js'; +import type { PipelineDefinition, PipelineDefinitionWithSubgraph } from './core/types.js'; + +/** 角色默认调度(与旧版 Runner 内置调度一致) */ +export const DEFAULT_ROLE_SCHEDULES: Record = { + reviewer: '*/10 * * * *', + maintainer: '*/10 * * * *', + archiver: '0 2 * * *', +}; + +/** + * 自动生成正本的首行标记。 + * 带标记的 pipeline.yaml 视为"配置投影":角色配置更新时会被重新生成; + * 用户编辑文件(删除标记)后即成为人类正本,配置更新不再触碰。 + */ +export const GENERATED_PIPELINE_MARKER = '# codekeeper:generated'; + +/** 为项目生成默认管线定义(纯函数,便于测试) */ +export function buildDefaultPipeline(project: Project): PipelineDefinition { + const nodes: PipelineDefinition['nodes'] = []; + const edges: PipelineDefinition['edges'] = []; + + for (const role of ROLES) { + const config = project.roles?.[role] as RoleConfig | undefined; + if (!isRoleConfigEnabled(config)) continue; + + const schedule = getRoleConfigSchedule(config)?.trim() || DEFAULT_ROLE_SCHEDULES[role]; + const triggerId = `trigger-${role}`; + const roleNodeId = `role-${role}`; + + nodes.push({ + id: triggerId, + type: 'trigger.cron', + label: `${role} 定时触发`, + params: { schedule }, + }); + nodes.push({ + id: roleNodeId, + type: `role.${role}`, + label: role, + params: {}, + }); + edges.push({ + from: { node: triggerId, port: 'tick' }, + to: { node: roleNodeId, port: 'trigger' }, + channel: 'memory', + artifactType: 'Tick', + }); + + // archiver 之后链知识投影:归档完成 → 正本 → EverOS 检索层 + if (role === 'archiver') { + nodes.push({ + id: 'knowledge-project', + type: 'knowledge.project', + label: '知识投影', + params: {}, + }); + edges.push({ + from: { node: roleNodeId, port: 'done' }, + to: { node: 'knowledge-project', port: 'in' }, + channel: 'memory', + artifactType: 'ArchiveDone', + }); + } + } + + return { + version: 1, + id: `pipeline-${project.id}`, + label: `${project.name} 默认管线`, + nodes, + edges, + }; +} + +/** 项目的管线正本路径 */ +export function getPipelineDefinitionPath(project: Project): string { + return join(project.rootPath, PIPELINE_DEFINITION_RELATIVE_PATH); +} + +/** + * 确保项目存在管线正本:缺失时从角色配置生成默认 pipeline.yaml。 + * 返回管线正本路径;项目无启用角色时返回 null(不生成空管线)。 + */ +export function ensurePipelineDefinition(project: Project): string | null { + const filePath = getPipelineDefinitionPath(project); + if (existsSync(filePath)) return filePath; + + const definition = buildDefaultPipeline(project); + if (definition.nodes.length === 0) return null; + + try { + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, GENERATED_PIPELINE_MARKER + '\n' + stringify(definition), 'utf-8'); + logger.info(`项目 ${project.name} 已生成默认管线定义: ${filePath}`); + return filePath; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`项目 ${project.name} 生成默认管线失败: ${message}`); + return null; + } +} + +/** + * 角色配置更新后同步管线正本:仅当现有文件仍带生成标记时重建 + * (人类编辑过的文件不再回写,调度以人类正本为准)。 + */ +export function regeneratePipelineDefinitionIfGenerated(project: Project): void { + const filePath = getPipelineDefinitionPath(project); + if (!existsSync(filePath)) return; + try { + const head = readFileSync(filePath, 'utf-8').slice(0, 200); + if (!head.startsWith(GENERATED_PIPELINE_MARKER)) return; + const definition = buildDefaultPipeline(project); + if (definition.nodes.length === 0) return; + writeFileSync(filePath, GENERATED_PIPELINE_MARKER + '\n' + stringify(definition), 'utf-8'); + logger.info(`项目 ${project.name} 的生成管线已随角色配置更新: ${filePath}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`项目 ${project.name} 重新生成管线定义失败: ${message}`); + } +} + +/** 读取并校验项目的管线定义;文件缺失返回 null,存在但非法时记录项目错误后返回 null。 */ +export function loadProjectPipeline(project: Project): PipelineDefinitionWithSubgraph | null { + const filePath = getPipelineDefinitionPath(project); + if (!existsSync(filePath)) return null; + try { + return parsePipelineDefinition(readFileSync(filePath, 'utf-8')); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`项目 ${project.name} 管线定义解析失败: ${message}`); + // 与非法 cron 同一上报口径:UI 项目状态可见 + recordProjectError(project, new Error(`管线定义解析失败: ${message}`), 'unknown'); + return null; + } +} diff --git a/src/advance/pipeline/pipeline-scheduler.ts b/src/advance/pipeline/pipeline-scheduler.ts new file mode 100644 index 0000000..583b5b5 --- /dev/null +++ b/src/advance/pipeline/pipeline-scheduler.ts @@ -0,0 +1,590 @@ +/** + * 管线调度器:daemon 侧的管线生命周期中枢。 + * + * 职责: + * - 项目加载时确保管线正本存在(旧角色配置自动迁移为默认 pipeline.yaml) + * - 按 trigger.cron 节点注册调度,触发后经 PipelineExecutor 执行该触发器的下游子图 + * - role.* 节点经 RoleNodeRuntime 派发到 (项目, 角色) 级子进程执行 + * - 对外保持旧 RoleServiceRegistry 的接口表面(start/stop/restartProject/getStatus), + * 供 IPC handlers 与 daemon 平滑切换 + */ + +import { schedule, validate as validateCron, type ScheduledTask } from 'node-cron'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { stringify } from 'yaml'; +import { logger } from '../core/logger.js'; +import type { HandlerContext } from '../ipc/handlers.js'; +import type { Project, Role } from '../types.js'; +import { ROLES } from '../types.js'; +import { recordAgentStarted, recordProjectError } from '../classic/status/project-status-store.js'; +import { PipelineExecutor } from './core/executor.js'; +import { PipelineRunStore } from './core/run-store.js'; +import type { NodeHandler, RunContext } from './core/types.js'; +import type { PipelineDefinition } from './core/types.js'; +import { + ensurePipelineDefinition, + GENERATED_PIPELINE_MARKER, + getPipelineDefinitionPath, + loadProjectPipeline, +} from './default-pipeline.js'; +import { pipelineWithSubgraphSchema, PipelineDefinitionError } from './core/types.js'; +import { topoSort } from './core/topology.js'; +import { RoleNodeRuntime } from '../classic/role-node-runtime.js'; +import { AgentRegistry } from '../agents/registry.js'; +import type { TaskEnvelope } from '../agents/task-envelope.js'; +import { projectKnowledgeToEverOS } from '../knowledge/knowledge-projection.js'; +import { distillKnowledgeCandidates } from '../knowledge/knowledge-distill.js'; +import { everosMemorySearchProject } from '../classic/memory/everos-api.js'; + +export interface PipelineSchedulerOptions { + /** EverOS MCP Server URL(由 daemon 启动后回设) */ + mcpUrl?: string; + /** CodeGraph Server URL(由 daemon 启动后回设) */ + codeGraphUrl?: string; +} + +/** 与旧 RoleServiceStatus 保持兼容的状态结构 */ +export interface RoleServiceStatus { + running: boolean; + enabledProjects: number; + runningProjects: string[]; +} + +export class PipelineScheduler { + public context: HandlerContext; + private readonly runtime: RoleNodeRuntime; + private runStore: PipelineRunStore | null = null; + private readonly activeRoles = new Set(); + private readonly registeredRoles = new Set(); + /** 已注册的触发任务:key = projectId:triggerNodeId */ + private readonly jobs = new Map< + string, + { projectId: string; branchRoles: Set; nodeId: string; task: ScheduledTask } + >(); + private options: PipelineSchedulerOptions; + + constructor(context: HandlerContext, runnerPath: string, options: PipelineSchedulerOptions = {}) { + this.context = context; + this.options = options; + this.runtime = new RoleNodeRuntime({ + runnerPath, + getDbPath: () => this.context.dbPath, + getDaemonConfig: () => this.context.getDaemonConfig?.() ?? {}, + getMcpUrl: () => this.options.mcpUrl ?? null, + getCodeGraphUrl: () => this.options.codeGraphUrl ?? null, + }); + } + + register(role: Role): void { + this.registeredRoles.add(role); + } + + /** 启动角色:等待基础设施 URL 就绪后激活该角色的全部管线触发器 */ + async start(role: Role): Promise { + if (!this.registeredRoles.has(role)) { + throw new Error(`角色 ${role} 未注册`); + } + await this.waitForMemoryMcpUrl(60000); + await this.waitForCodeGraphUrl(60000); + this.activeRoles.add(role); + this.rescheduleAll(); + logger.info(`[Pipeline] 角色 ${role} 的管线触发器已激活`); + // 行为对拍:旧版 startProjectLoop 会立即执行一次,这里激活后立即触发一轮 + for (const project of this.context.store.getRoleEnabledProjects(role)) { + void this.runRoleNow(project, role).catch(error => { + const message = error instanceof Error ? error.message : String(error); + logger.error(`[Pipeline] 项目 ${project.name} 角色 ${role} 首次执行失败: ${message}`); + }); + } + } + + /** 停止角色:摘除触发器并终止该角色的全部节点子进程 */ + async stop(role: Role): Promise { + this.activeRoles.delete(role); + for (const [key, entry] of [...this.jobs.entries()]) { + if (entry.branchRoles.has(role)) { + entry.task.stop(); + this.jobs.delete(key); + } + } + this.runtime.stopRole(role); + logger.info(`[Pipeline] 角色 ${role} 的管线触发器已停止`); + } + + /** + * 重启指定项目的角色节点:终止其节点子进程,下次触发时惰性重建。 + * (旧实现会重启整个角色服务;节点实例化后重启粒度收敛为单项目单角色。) + * 若项目该角色处于启用且激活状态,立即执行一轮以对账(对齐旧版重启后立即执行的语义)。 + */ + async restartProject(role: Role, projectId: string): Promise { + this.runtime.stopInstance(projectId, role); + if (!this.activeRoles.has(role)) { + await this.start(role); + return; + } + this.rescheduleAll(); + const project = this.context.store.getProject(projectId); + if (project) { + const enabled = this.context.store.getRoleEnabledProjects(role).some(p => p.id === projectId); + if (enabled) { + // 异步对账:不把整轮角色执行(可达数分钟)阻塞在配置保存 IPC 上 + void this.runRoleNow(project, role).catch(error => { + const message = error instanceof Error ? error.message : String(error); + logger.error( + `[Pipeline] 项目 ${project.name} 角色 ${role} 重启后立即执行失败: ${message}` + ); + }); + } + } + } + + /** 整角色重启(不传项目时):重建该角色全部实例并重排,立即对账启用项目 */ + async restartRole(role: Role): Promise { + this.runtime.stopRole(role); + if (!this.activeRoles.has(role)) { + await this.start(role); + return; + } + this.rescheduleAll(); + for (const project of this.context.store.getRoleEnabledProjects(role)) { + void this.runRoleNow(project, role).catch(error => { + const message = error instanceof Error ? error.message : String(error); + logger.error(`[Pipeline] 项目 ${project.name} 角色 ${role} 重启后立即执行失败: ${message}`); + }); + } + } + + getStatus(role: Role): RoleServiceStatus { + const enabledProjects = this.context.store.getRoleEnabledProjects(role); + return { + running: this.activeRoles.has(role), + enabledProjects: enabledProjects.length, + runningProjects: enabledProjects.map(p => p.id), + }; + } + + /** daemon 启动 EverOS 后回设 */ + setMemoryMcpUrl(url: string): void { + this.options = { ...this.options, mcpUrl: url }; + } + + /** daemon 启动 CodeGraph 后回设 */ + setCodeGraphUrl(url: string): void { + this.options = { ...this.options, codeGraphUrl: url }; + } + + /** + * 项目注册/配置变化后重建其调度,并重启该项目全部节点实例 + * (子进程持有启动时项目快照,必须重建才能读到新配置)。 + * 角色配置更新、项目注册、画布写回 YAML 都经此感知。 + */ + reloadProject(projectId: string): void { + for (const key of [...this.jobs.keys()]) { + if (this.jobs.get(key)?.projectId === projectId) { + this.jobs.get(key)?.task.stop(); + this.jobs.delete(key); + } + } + this.runtime.stopProject(projectId); + const project = this.context.store.getProject(projectId); + if (project) { + this.scheduleProject(project); + } + } + + /** 项目注销:摘除其全部触发器并终止节点实例 */ + unloadProject(projectId: string): void { + for (const key of [...this.jobs.keys()]) { + if (this.jobs.get(key)?.projectId === projectId) { + this.jobs.get(key)?.task.stop(); + this.jobs.delete(key); + } + } + this.runtime.stopProject(projectId); + } + + /** 全部项目重排(角色启停、daemon 启动时调用) */ + rescheduleAll(): void { + for (const entry of this.jobs.values()) entry.task.stop(); + this.jobs.clear(); + for (const project of this.context.store.listProjects()) { + this.scheduleProject(project); + } + } + + /** daemon 关闭:停全部触发器与节点子进程 */ + async stopAll(): Promise { + for (const entry of this.jobs.values()) entry.task.stop(); + this.jobs.clear(); + this.runtime.stopAll(); + } + + /** + * 读取项目管线(画布用):不存在时先生成默认管线。 + * generated=true 表示正本仍是自动投影(画布保存后转人类正本)。 + */ + getProjectPipeline(projectId: string): { + exists: boolean; + generated: boolean; + definition: PipelineDefinition | null; + } { + const project = this.context.store.getProject(projectId); + if (!project) throw new Error(`项目不存在: ${projectId}`); + ensurePipelineDefinition(project); + + const filePath = getPipelineDefinitionPath(project); + let exists = false; + let generated = false; + try { + const head = readFileSync(filePath, 'utf-8').slice(0, 200); + exists = true; + generated = head.startsWith(GENERATED_PIPELINE_MARKER); + } catch { + // 文件不存在:项目无启用角色 + } + return { exists, generated, definition: loadProjectPipeline(project) }; + } + + /** + * 画布写回:校验通过后覆盖 pipeline.yaml(不带生成标记,转人类正本), + * 并立即热加载(重排触发器 + 重启节点实例)。 + */ + updateProjectPipeline(projectId: string, definition: unknown): void { + const project = this.context.store.getProject(projectId); + if (!project) throw new Error(`项目不存在: ${projectId}`); + + const parsed = pipelineWithSubgraphSchema.parse(definition); + validatePipelineDeep(parsed); // 逐层结构校验 + 环检测 + assertNoSecretParams(parsed); + + writeFileSync(getPipelineDefinitionPath(project), stringify(parsed), 'utf-8'); + this.reloadProject(projectId); + logger.info(`[Pipeline] 项目 ${project.name} 管线定义已由画布更新`); + } + + /** 项目的最近运行记录(画布运行状态叠加用;收窄为传输形状,剥离定义与产物) */ + listPipelineRuns( + projectId: string, + limit = 20 + ): Array<{ + id: string; + status: string; + error: string | null; + createdAt: number; + finishedAt: number | null; + stages: Array<{ + nodeId: string; + status: string; + error: string | null; + startedAt: number; + finishedAt: number | null; + }>; + }> { + return this.getRunStore() + .listRunsByProject(projectId, limit) + .map(run => ({ + id: run.id, + status: run.status, + error: run.error, + createdAt: run.createdAt, + finishedAt: run.finishedAt, + stages: this.getRunStore() + .getStageRuns(run.id) + .map(stage => ({ + nodeId: stage.nodeId, + status: stage.status, + error: stage.error, + startedAt: stage.startedAt, + finishedAt: stage.finishedAt, + })), + })); + } + + private scheduleProject(project: Project): void { + ensurePipelineDefinition(project); + const definition = loadProjectPipeline(project); + if (!definition) return; + + // 按 trigger.cron 节点注册调度:一个触发器拉起其整个下游分支 + for (const trigger of definition.nodes) { + if (trigger.type !== 'trigger.cron') continue; + const scheduleExpr = trigger.params.schedule; + if (typeof scheduleExpr !== 'string' || !validateCron(scheduleExpr)) { + const message = `项目 ${project.name} 节点 ${trigger.id} 的 cron 非法: ${String(scheduleExpr)}`; + logger.error(`[Pipeline] ${message}`); + recordProjectError(project, new Error(message), 'unknown'); + continue; + } + + // 分支语义:含 role.* 节点的分支只在该角色激活且项目启用时调度; + // 纯 knowledge.*/agent.* 分支(无角色节点)由 daemon 进程内执行,始终可调度 + const branchRoles = collectBranchRoles(definition, trigger.id); + if (branchRoles.size > 0) { + const runnable = [...branchRoles].some( + role => + this.activeRoles.has(role) && + this.context.store.getRoleEnabledProjects(role).some(p => p.id === project.id) + ); + if (!runnable) continue; + } + + const key = `${project.id}:${trigger.id}`; + const task = schedule(scheduleExpr, () => { + void this.executePipelineBranch(project, definition, trigger.id).catch(error => { + const message = error instanceof Error ? error.message : String(error); + logger.error(`[Pipeline] 项目 ${project.name} 触发器 ${trigger.id} 执行失败: ${message}`); + }); + }); + this.jobs.set(key, { projectId: project.id, branchRoles, nodeId: trigger.id, task }); + logger.info(`[Pipeline] 项目 ${project.name} 触发器 ${trigger.id} 已调度: ${scheduleExpr}`); + } + } + + /** 手动触发某项目的角色节点立即执行(MCP 门面 / 未来 UI 按钮用) */ + async runProjectRoleNow(projectId: string, role: Role): Promise { + const project = this.context.store.getProject(projectId); + if (!project) throw new Error(`项目不存在: ${projectId}`); + ensurePipelineDefinition(project); + await this.runRoleNow(project, role); + } + + /** 立即执行某项目的角色节点(启动/重启后的对账轮):从上游触发器拉起整个分支 */ + private async runRoleNow(project: Project, role: Role): Promise { + const definition = loadProjectPipeline(project); + const roleNode = definition?.nodes.find(node => node.type === `role.${role}`); + if (!definition || !roleNode) return; + const trigger = this.findUpstreamTrigger(definition, roleNode.id); + await this.executePipelineBranch(project, definition, trigger?.id ?? roleNode.id); + } + + /** 从指定节点(通常是 trigger)执行下游子图 */ + private async executePipelineBranch( + project: Project, + definition: PipelineDefinition, + startFromNodeId: string + ): Promise { + const handlers = this.buildNodeHandlers(project, definition); + const executor = new PipelineExecutor(handlers, this.getRunStore()); + await executor.execute(definition, this.buildRunContext(project), { + projectId: project.id, + startFrom: [startFromNodeId], + }); + } + + private buildNodeHandlers( + project: Project, + definition?: PipelineDefinition + ): Map { + const handlers = new Map(); + handlers.set('trigger.cron', { + type: 'trigger.cron', + outputs: ['tick'], + run: async () => ({ tick: new Date().toISOString() }), + }); + + // 角色节点:分支内出现的每个角色类型都注册(角色从当前节点类型派生) + if (definition) { + for (const node of definition.nodes) { + if (!node.type.startsWith('role.') || handlers.has(node.type)) continue; + const role = node.type.slice('role.'.length) as Role; + if (!ROLES.includes(role)) continue; + handlers.set(node.type, { + type: node.type, + inputs: ['trigger'], + outputs: ['done'], + run: async (_ctx, _inputs, _params, currentNode) => { + const currentRole = currentNode.type.slice('role.'.length) as Role; + // 对拍旧版 startProjectLoop 的 agentStarted 状态记录 + recordAgentStarted(project); + await this.runtime.runOnce(project, currentRole); + return { done: true }; + }, + }); + } + } + + // 外部 Agent 节点:经注册表解析传输适配器,任务信封交互 + if (definition) { + const registry = new AgentRegistry(this.context.getDaemonConfig?.().agents ?? []); + for (const node of definition.nodes) { + if (!node.type.startsWith('agent.') || handlers.has(node.type)) continue; + handlers.set(node.type, { + type: node.type, + run: async (ctx, inputs, params, currentNode) => { + const transport = registry.resolveTransport(node.type, params); + const capability = + typeof params.capability === 'string' ? params.capability : node.type; + const envelope: TaskEnvelope = { + id: `${ctx.vars.runId ?? 'run'}:${currentNode.id}`, + capability, + input: inputs, + artifacts: Object.entries(inputs).map(([name, value]) => ({ + name, + type: 'PipelineArtifact', + content: typeof value === 'string' ? value : JSON.stringify(value), + })), + timeoutMs: typeof params.timeoutMs === 'number' ? params.timeoutMs : undefined, + }; + const result = await transport.execute(envelope); + if (result.status === 'failed') { + throw new Error(`外部 Agent 执行失败: ${result.error ?? '未知错误'}`); + } + return result.output; + }, + }); + } + } + // 知识节点:正本投影(→EverOS)与经验蒸馏(→人审草稿箱) + if (definition) { + if (definition.nodes.some(node => node.type === 'knowledge.project')) { + handlers.set('knowledge.project', { + type: 'knowledge.project', + run: async () => { + const everosUrl = this.context.everosUrl; + if (!everosUrl) throw new Error('EverOS 未就绪,无法投影知识'); + const result = await projectKnowledgeToEverOS( + this.context.store.database, + everosUrl, + project + ); + return { ...result }; + }, + }); + } + if (definition.nodes.some(node => node.type === 'knowledge.distill')) { + handlers.set('knowledge.distill', { + type: 'knowledge.distill', + run: async (_ctx, _inputs, params) => { + const llm = this.context.getClient(); + if (!llm) throw new Error('LLM 未配置,无法蒸馏知识'); + const everosUrl = this.context.everosUrl; + if (!everosUrl) throw new Error('EverOS 未就绪,无法收集经验'); + const queries = Array.isArray(params.experienceQueries) + ? params.experienceQueries.filter((q): q is string => typeof q === 'string') + : ['修复经验', '评审约定', '项目约定', '架构决策']; + const experiences: string[] = []; + for (const query of queries) { + const recalled = await everosMemorySearchProject(everosUrl, { + appId: 'codekeeper-advance', + projectId: project.id, + query, + topK: 5, + }); + for (const item of recalled.items) { + if (item.content) experiences.push(item.content); + } + } + const distilled = await distillKnowledgeCandidates(project, llm, experiences); + return { candidates: distilled.candidates, written: distilled.written.length }; + }, + }); + } + } + return handlers; + } + + private buildRunContext(project: Project): RunContext { + return { + logger: { + info: msg => logger.info(msg), + warn: msg => logger.warn(msg), + error: msg => logger.error(msg), + }, + services: { project }, + vars: { projectId: project.id }, + }; + } + + private getRunStore(): PipelineRunStore { + if (!this.runStore) { + this.runStore = new PipelineRunStore(this.context.store.database); + } + return this.runStore; + } + + /** 找到角色节点上游的 trigger.cron 节点(默认管线为一对一结构) */ + private findUpstreamTrigger(definition: PipelineDefinition, roleNodeId: string) { + const upstreamIds = new Set( + definition.edges.filter(edge => edge.to.node === roleNodeId).map(edge => edge.from.node) + ); + return definition.nodes.find(node => node.type === 'trigger.cron' && upstreamIds.has(node.id)); + } + + private async waitForMemoryMcpUrl(timeoutMs = 60000): Promise { + const start = Date.now(); + while (!this.options.mcpUrl) { + if (Date.now() - start > timeoutMs) { + throw new Error('等待 EverOS MCP URL 超时,无法启动角色服务'); + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + 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)); + } + } +} + +/** 收集某节点下游分支中出现的角色类型集合 */ +function collectBranchRoles(definition: PipelineDefinition, startNodeId: string): Set { + const downstream = new Map(); + for (const edge of definition.edges) { + downstream.set(edge.from.node, [...(downstream.get(edge.from.node) ?? []), edge.to.node]); + } + const roles = new Set(); + const queue = [startNodeId]; + const visited = new Set(); + while (queue.length > 0) { + const id = queue.shift(); + if (id === undefined || visited.has(id)) continue; + visited.add(id); + const node = definition.nodes.find(n => n.id === id); + if (node?.type.startsWith('role.')) { + const role = node.type.slice('role.'.length) as Role; + if (ROLES.includes(role)) roles.add(role); + } + queue.push(...(downstream.get(id) ?? [])); + } + return roles; +} + +/** 疑似凭据的 params 键名(params 明文落库,凭据只允许经 services 注入) */ +const SECRET_PARAM_KEYS = new Set([ + 'token', + 'apikey', + 'api_key', + 'secret', + 'password', + 'authorization', +]); + +/** 拒绝把疑似凭据写进管线定义(画布与手编 YAML 同一约束);递归覆盖子图 */ +function assertNoSecretParams(definition: PipelineDefinition): void { + for (const node of definition.nodes) { + for (const key of Object.keys(node.params)) { + const normalized = key.toLowerCase().replace(/[-_]/g, ''); + if (SECRET_PARAM_KEYS.has(normalized) || normalized.endsWith('token')) { + throw new PipelineDefinitionError( + `节点 ${node.id} 的 params 含疑似凭据键 "${key}";凭据只允许经服务注入,不得写入管线定义` + ); + } + } + const subgraph = (node as { subgraph?: PipelineDefinition }).subgraph; + if (subgraph) assertNoSecretParams(subgraph); + } +} + +/** 深度校验:每层图都做结构校验 + 环检测(写回路径的统一入口) */ +export function validatePipelineDeep(definition: PipelineDefinition): void { + topoSort(definition); // 含 validateGraph + 环检测 + for (const node of definition.nodes) { + const subgraph = (node as { subgraph?: PipelineDefinition }).subgraph; + if (subgraph) validatePipelineDeep(subgraph); + } +} diff --git a/src/advance/scan/scan-service.ts b/src/advance/scan/scan-service.ts index 95beee0..c1a2e5f 100755 --- a/src/advance/scan/scan-service.ts +++ b/src/advance/scan/scan-service.ts @@ -6,7 +6,7 @@ import { fork, type ChildProcess } from 'node:child_process'; import path from 'node:path'; -import { logger } from '../../core/logger.js'; +import { logger } from '../core/logger.js'; import type { MetadataStore } from '../store/metadata-store.js'; import type { ProjectRegistry } from '../project-registry.js'; import type { Project } from '../types.js'; @@ -110,20 +110,17 @@ export class ScanService { }); this.child = child; - child.stdout?.on('data', (data) => { + child.stdout?.on('data', data => { logger.info({ output: data.toString().trim() }, '[Scan Worker]'); }); - child.stderr?.on('data', (data) => { + child.stderr?.on('data', data => { logger.warn({ output: data.toString().trim() }, '[Scan Worker]'); }); child.on('message', (msg: ScanWorkerMessage) => { switch (msg.type) { case 'progress': - logger.info( - { projectId: msg.projectId, stage: msg.stage }, - '[ScanService] 扫描进度' - ); + logger.info({ projectId: msg.projectId, stage: msg.stage }, '[ScanService] 扫描进度'); break; case 'done': logger.info('[ScanService] 扫描任务完成'); @@ -134,7 +131,7 @@ export class ScanService { } }); - child.on('exit', (code) => { + child.on('exit', code => { logger.info({ exitCode: code }, '[ScanService] 扫描 worker 退出'); this.child = null; this.running = false; diff --git a/src/advance/scan/scan-worker-entry.ts b/src/advance/scan/scan-worker-entry.ts index f762f95..b5ffb8e 100755 --- a/src/advance/scan/scan-worker-entry.ts +++ b/src/advance/scan/scan-worker-entry.ts @@ -87,7 +87,7 @@ async function runScan(task: ScanWorkerTask): Promise { } process.on('message', (task: ScanWorkerTask) => { - runScan(task).catch((err) => { + runScan(task).catch(err => { const message = err instanceof Error ? err.message : String(err); console.error('[Scan Worker] 扫描任务失败:', message); sendMessage({ type: 'error', message }); diff --git a/src/advance/store/metadata-store.ts b/src/advance/store/metadata-store.ts index 3e7e8c3..c8fa58c 100755 --- a/src/advance/store/metadata-store.ts +++ b/src/advance/store/metadata-store.ts @@ -35,6 +35,14 @@ export class MetadataStore { this.migrate(); } + /** + * 暴露底层数据库句柄,供同进程的其他 store(如 PipelineRunStore)共享连接。 + * WAL 模式下多 store 共用同一连接是安全的。 + */ + get database(): Database.Database { + return this.db; + } + private migrate(): void { // 旧版 projects 表字段迁移 const projectColumns = this.db.prepare('PRAGMA table_info(projects)').all() as Array<{ @@ -636,11 +644,43 @@ export class MetadataStore { } listActionHistory( - projectId: string + projectId: string, + limit = 100 ): Array { const rows = this.db - .prepare('SELECT * FROM action_history WHERE project_id = ? ORDER BY applied_at DESC') - .all(projectId) as Array<{ + .prepare('SELECT * FROM action_history WHERE project_id = ? ORDER BY applied_at DESC LIMIT ?') + .all(projectId, limit) 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 => ({ + historyId: r.id, + id: r.action_id, + projectId: r.project_id, + sourcePath: r.source_path, + type: r.type as ArchiveAction['type'], + targetPath: r.archive_path ?? r.target_path ?? undefined, + risk: 'low', + reason: '', + confidence: 0, + createdAt: r.applied_at, + status: r.status as 'applied' | 'undone', + })); + } + + listActionHistoryAll( + limit = 100 + ): Array { + const rows = this.db + .prepare('SELECT * FROM action_history ORDER BY applied_at DESC LIMIT ?') + .all(limit) as Array<{ id: number; action_id: string; project_id: string; @@ -884,6 +924,12 @@ export class MetadataStore { .run(JSON.stringify(config), projectId); } + updateProjectArchiveRoot(projectId: string, archiveRoot: string | null): void { + this.db + .prepare('UPDATE projects SET archive_root = ? WHERE id = ?') + .run(archiveRoot, projectId); + } + updateProjectRoleConfig(projectId: string, role: Role, config: RoleConfig): void { const project = this.getProject(projectId); if (!project) throw new Error(`项目不存在: ${projectId}`); diff --git a/src/advance/store/schema.sql b/src/advance/store/schema.sql index 4800682..c11b094 100755 --- a/src/advance/store/schema.sql +++ b/src/advance/store/schema.sql @@ -155,3 +155,45 @@ CREATE TABLE IF NOT EXISTS pending_memory_writes ( CREATE INDEX IF NOT EXISTS idx_pending_memory_project ON pending_memory_writes(project_id); CREATE INDEX IF NOT EXISTS idx_pending_memory_next_retry ON pending_memory_writes(next_retry_at); + +-- 管线运行记录(M2 管线核心) +CREATE TABLE IF NOT EXISTS pipeline_runs ( + id TEXT PRIMARY KEY, + pipeline_id TEXT NOT NULL, + project_id TEXT, + status TEXT NOT NULL CHECK(status IN ('running', 'succeeded', 'failed', 'cancelled')), + definition_json TEXT NOT NULL, + error TEXT, + created_at INTEGER NOT NULL, + finished_at INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_pipeline_runs_pipeline ON pipeline_runs(pipeline_id); +CREATE INDEX IF NOT EXISTS idx_pipeline_runs_status ON pipeline_runs(status); + +-- 节点级运行记录:每个节点边界落库,支持断点恢复与观测 +CREATE TABLE IF NOT EXISTS stage_runs ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + node_id TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('running', 'succeeded', 'failed', 'skipped')), + inputs_json TEXT, + outputs_json TEXT, + error TEXT, + started_at INTEGER NOT NULL, + finished_at INTEGER, + UNIQUE(run_id, node_id), + FOREIGN KEY (run_id) REFERENCES pipeline_runs(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_stage_runs_run ON stage_runs(run_id); + +-- 知识正本 → EverOS 投影的去重状态(M6 智库;root 维度区分双正本同 id) +CREATE TABLE IF NOT EXISTS knowledge_projection ( + project_id TEXT NOT NULL, + root TEXT NOT NULL DEFAULT 'shared', + knowledge_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + synced_at INTEGER NOT NULL, + PRIMARY KEY (project_id, root, knowledge_id) +); diff --git a/src/advance/utils/git-info.ts b/src/advance/utils/git-info.ts index 2c7d3a7..8912bf9 100755 --- a/src/advance/utils/git-info.ts +++ b/src/advance/utils/git-info.ts @@ -24,7 +24,7 @@ export async function detectGitInfo(projectRoot: string): Promise { let remoteUrl: string | undefined; try { const remotes = await git.getRemotes(true); - const origin = remotes.find((r) => r.name === 'origin'); + const origin = remotes.find(r => r.name === 'origin'); remoteUrl = origin?.refs.fetch ?? origin?.refs.push; } catch { // 无法读取 remote 时继续返回空分支列表 diff --git a/src/assets/prompts/knowledge-distill.md b/src/assets/prompts/knowledge-distill.md new file mode 100644 index 0000000..952696e --- /dev/null +++ b/src/assets/prompts/knowledge-distill.md @@ -0,0 +1,27 @@ +你是项目智库的蒸馏器。阅读以下 Agent 角色在项目中积累的经验记录(评审、修复、归档、交互), +提炼出值得长期保留的项目知识候选。 + +## 经验记录 +{{experiences}} + +## 要求 +1. 只提炼稳定的项目知识(约定、架构决策、领域概念、风险点、技术栈事实), + 不要把一次性事件、临时讨论、个别 MR 的细节当作知识。 +2. 每条候选必须能在经验记录中找到依据,禁止编造。 +3. 宁缺毋滥:没有值得提炼的内容时返回空列表。 +4. 每条候选给出:id(小写 slug)、title、category(convention|architecture|domain|risk|stack|graph)、 + confidence(high|medium|low)、tags、body(Markdown 正文,100~300 字)。 + +## 输出格式(严格 JSON,不要输出其他内容) +{ + "candidates": [ + { + "id": "api-auth-convention", + "title": "API 鉴权约定", + "category": "convention", + "confidence": "medium", + "tags": ["api", "auth"], + "body": "……" + } + ] +} diff --git a/src/ast-grep/runner.ts b/src/ast-grep/runner.ts deleted file mode 100755 index 1aabe5f..0000000 --- a/src/ast-grep/runner.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { existsSync } from 'node:fs'; -import { execSync } from 'child_process'; -import { logger } from '../core/logger.js'; -import type { AstGrepFinding } from '../types.js'; - -/** - * Run ast-grep scan on given files - */ -export function runAstGrep(configPath: string, filePaths: string[]): AstGrepFinding[] { - if (filePaths.length === 0) { - return []; - } - - // 只保留实际存在的文件 - const existingFiles = filePaths.filter((f) => existsSync(f)); - - if (existingFiles.length === 0) { - return []; - } - - try { - const result = execSync( - `ast-grep scan --config "${configPath}" --json ${existingFiles.map((f) => `"${f}"`).join(' ')}`, - { - encoding: 'utf8', - timeout: 30000, - maxBuffer: 10 * 1024 * 1024, // 10MB - } - ); - - return parseAstGrepOutput(result); - } catch (err) { - // ast-grep returns non-zero when findings exist - const errorOutput = (err as Error & { stdout?: string; stderr?: string }).stdout; - if (errorOutput) { - return parseAstGrepOutput(errorOutput); - } - logger.warn({ err: (err as Error).message }, 'ast-grep scan failed'); - return []; - } -} - -/** - * Parse ast-grep JSON output - */ -function parseAstGrepOutput(output: string): AstGrepFinding[] { - try { - const parsed = JSON.parse(output); - - if (Array.isArray(parsed)) { - return parsed.flatMap((entry: Record) => { - const file = String(entry.file || ''); - const rules = entry.rules || []; - return (Array.isArray(rules) ? rules : []).map((rule: Record) => ({ - file, - line: Number(rule.line || 0), - column: Number(rule.column || 0), - ruleId: String(rule.id || rule.ruleId || 'unknown'), - message: String(rule.message || rule.text || ''), - severity: String(rule.severity || rule.level || 'warning'), - })); - }); - } - - // Alternative format: object keyed by file - const findings: AstGrepFinding[] = []; - for (const [file, rules] of Object.entries(parsed)) { - if (Array.isArray(rules)) { - for (const rule of rules) { - findings.push({ - file, - line: Number(rule.line || 0), - column: Number(rule.column || 0), - ruleId: String(rule.id || rule.ruleId || 'unknown'), - message: String(rule.message || rule.text || ''), - severity: String(rule.severity || rule.level || 'warning'), - }); - } - } - } - return findings; - } catch (err) { - logger.warn({ err: (err as Error).message }, 'Failed to parse ast-grep output'); - return []; - } -} diff --git a/src/cli.ts b/src/cli.ts deleted file mode 100755 index e8fdef3..0000000 --- a/src/cli.ts +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env node -import 'dotenv/config'; -import { loadConfig } from './config-loader.js'; -import { logger } from './core/logger.js'; -import { runReview, runLearning } from './index.js'; -import { startScheduler, stopAll, listJobs } from './scheduler.js'; - -const args = process.argv.slice(2); -const command = args[0] || 'run'; - -function showHelp(): void { - console.log(` -CodeKeeper — Code Orchestrator & Review Node - -Usage: - codekeeper [options] - -Commands: - run Run review + learning once for all projects - review Run review only - learn Run learning loop only - daemon Start scheduler daemon (cron mode) - status Show scheduler status - stop Stop daemon (sends SIGTERM) - register Register a new project from local path - validate Validate all project configs - -Options: - --project= Target specific project - --config= Use custom config file - --daemon Run in daemon mode (with 'run' command) - -Examples: - codekeeper run # Single run for all projects - codekeeper run --project=my-project # Single run for one project - codekeeper review # Review only - codekeeper daemon # Start background scheduler - codekeeper register ./my-project # Auto-register project -`); -} - -async function main(): Promise { - const projectArg = args.find((arg) => arg.startsWith('--project=')); - const configPath = args.find((arg) => arg.startsWith('--config=')); - const isDaemon = args.includes('--daemon'); - - let config; - try { - config = loadConfig(configPath?.split('=')[1]); - } catch (err) { - console.error('Failed to load config:', (err as Error).message); - process.exit(1); - } - - // Filter projects - let projects = config.projects; - if (projectArg) { - const projectId = projectArg.split('=')[1]; - projects = projects.filter((p) => p.id === projectId); - if (projects.length === 0) { - console.error(`Project "${projectId}" not found`); - process.exit(1); - } - } - - switch (command) { - case 'run': { - if (isDaemon) { - startScheduler(projects); - } else { - for (const project of projects) { - if (project.review.enabled) await runReview(project); - if (project.learning.enabled) await runLearning(project); - } - logger.info('Run complete'); - } - break; - } - - case 'review': { - for (const project of projects) { - if (project.review.enabled) await runReview(project); - } - break; - } - - case 'learn': { - for (const project of projects) { - if (project.learning.enabled) await runLearning(project); - } - break; - } - - case 'daemon': { - startScheduler(projects); - break; - } - - case 'status': { - const jobs = listJobs(); - if (jobs.length === 0) { - console.log('No active scheduled jobs'); - } else { - console.log('Active jobs:'); - for (const job of jobs) { - console.log(` - ${job}`); - } - } - break; - } - - case 'stop': { - stopAll(); - logger.info('Scheduler stopped'); - break; - } - - case 'validate': { - let hasErrors = false; - for (const project of projects) { - const { validateProject } = await import('./config-loader.js'); - const errors = validateProject(project); - if (errors.length === 0) { - console.log(`✅ ${project.id}: valid`); - } else { - console.log(`❌ ${project.id}:`); - for (const err of errors) { - console.log(` - ${err}`); - } - hasErrors = true; - } - } - if (hasErrors) process.exit(1); - break; - } - - case 'register': { - const projectPath = args[1]; - if (!projectPath) { - console.error('Usage: codekeeper register '); - process.exit(1); - } - console.log('Auto-register not yet implemented. Please manually edit config/projects.yaml'); - break; - } - - case 'help': - case '--help': - case '-h': - default: - showHelp(); - break; - } -} - -main().catch((err) => { - logger.fatal({ err }, 'CLI error'); - process.exit(1); -}); diff --git a/src/config-loader.ts b/src/config-loader.ts deleted file mode 100755 index 24cc378..0000000 --- a/src/config-loader.ts +++ /dev/null @@ -1,267 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import YAML from 'yaml'; -import { z } from 'zod'; -import { logger } from './core/logger.js'; -import { getConfigDir } from './core/platform.js'; -import type { ProjectConfig } from './types.js'; - -const ReviewFilterSchema = z.object({ - excludeAuthors: z.array(z.string()).default([]), - excludeDrafts: z.boolean().default(true), - minChanges: z.number().int().min(0).default(1), - maxChanges: z.number().int().min(1).default(300), - excludePaths: z.array(z.string()).default(['**/*.md', 'docs/**']), -}); - -const ProjectSchema = z.object({ - id: z.string().regex(/^[a-z0-9-]+$/), - name: z.string(), - localPath: z.string(), - git: z.object({ - remote: z.string(), - defaultBranch: z.string().default('main'), - }), - gitlab: z.object({ - baseUrl: z.string().url(), - projectPath: z.string(), - token: z.string(), - }), - review: z.object({ - enabled: z.boolean().default(true), - schedule: z.string().default('*/30 * * * *'), - timezone: z.string().default('Asia/Shanghai'), - tokenBudget: z.number().int().min(1000).default(200000), - autoFix: z.boolean().default(false), - autoFixBranchPrefix: z.string().default('codekeeper/fix-'), - rulesFile: z.string().default('CLAUDE.md'), - astGrepConfig: z.string().default('.claude/rules/sgconfig.yml'), - filter: ReviewFilterSchema.default({}), - }), - learning: z.object({ - enabled: z.boolean().default(true), - schedule: z.string().default('0 2 * * *'), - patternThreshold: z.number().int().min(1).default(3), - updateClaudeMd: z.boolean().default(true), - createAstGrepRules: z.boolean().default(true), - lookbackDays: z.number().int().min(1).default(7), - }), -}); - -const ConfigSchema = z.object({ - projects: z.array(ProjectSchema).min(1), -}); - -/** - * Load configuration from environment variables (.env driven) - * Falls back to projects.yaml if no env-based projects found - */ -export function loadConfig(configPath?: string): { projects: ProjectConfig[] } { - // Try env-based config first - const envProjects = parseEnvProjects(); - if (envProjects.length > 0) { - logger.info(`Loaded ${envProjects.length} project(s) from environment variables`); - return { projects: envProjects }; - } - - // Fallback to YAML config file - const fileConfig = loadFileConfig(configPath); - if (fileConfig) { - logger.info(`Loaded ${fileConfig.projects.length} project(s) from config file`); - return fileConfig; - } - - throw new Error( - 'No projects configured.\n\n' + - 'Option 1: Set environment variables in .env:\n' + - ' CODEKEEPER_PROJECT_{ID}_PATH=/path/to/project\n' + - ' CODEKEEPER_PROJECT_{ID}_GITLAB_URL=https://git.example.com\n' + - ' CODEKEEPER_PROJECT_{ID}_GITLAB_PROJECT=group/project\n' + - ' CODEKEEPER_PROJECT_{ID}_TOKEN=glpat-xxxxx\n\n' + - 'Option 2: Create config/projects.yaml (see config/projects.example.yaml)' - ); -} - -/** - * Parse project configurations from CODEKEEPER_PROJECT_* environment variables - * - * Expected format: - * CODEKEEPER_PROJECT_MYPROJECT_PATH=/path/to/project - * CODEKEEPER_PROJECT_MYPROJECT_GITLAB_URL=https://git.example.com - * CODEKEEPER_PROJECT_MYPROJECT_GITLAB_PROJECT=group/project - * CODEKEEPER_PROJECT_MYPROJECT_TOKEN=glpat-xxxxx - * CODEKEEPER_PROJECT_MYPROJECT_SCHEDULE=\*\/30 * * * * (optional) - * CODEKEEPER_PROJECT_MYPROJECT_AUTO_FIX=false (optional) - */ -function parseEnvProjects(): ProjectConfig[] { - const prefix = 'CODEKEEPER_PROJECT_'; - const projectKeys = new Map>(); - - // Group env vars by project ID - for (const [key, value] of Object.entries(process.env)) { - if (!key.startsWith(prefix) || !value) continue; - - // Extract project ID and field name - // e.g. CODEKEEPER_PROJECT_MYPROJECT_PATH -> id=MYPROJECT, field=PATH - const rest = key.slice(prefix.length); // e.g. "MYPROJECT_PATH" - const firstUnderscore = rest.indexOf('_'); - if (firstUnderscore === -1) continue; - - const id = rest.slice(0, firstUnderscore).toLowerCase(); - const field = rest.slice(firstUnderscore + 1); - - if (!projectKeys.has(id)) { - projectKeys.set(id, {}); - } - const projectEntry = projectKeys.get(id); - if (projectEntry) { - projectEntry[field] = value; - } - } - - const projects: ProjectConfig[] = []; - - for (const [id, fields] of projectKeys) { - // Skip projects without required fields - if (!fields.PATH) { - logger.warn(`Project "${id}" missing PATH, skipping`); - continue; - } - - const project = buildProjectConfig(id, fields); - projects.push(project); - } - - return projects; -} - -function buildProjectConfig(id: string, fields: Record): ProjectConfig { - const name = id.split('-').map(capitalize).join(' '); - - return { - id, - name, - localPath: path.resolve(resolveEnvValue(fields.PATH)), - git: { - remote: fields.GIT_REMOTE || `git@${extractHost(fields.GITLAB_URL || '')}:${fields.GITLAB_PROJECT || ''}.git`, - defaultBranch: fields.GIT_BRANCH || 'main', - }, - gitlab: { - baseUrl: fields.GITLAB_URL || 'https://gitlab.com', - projectPath: fields.GITLAB_PROJECT || '', - token: resolveEnvValue(fields.TOKEN || ''), - }, - review: { - enabled: parseBool(fields.ENABLED, true), - schedule: fields.SCHEDULE || process.env.CODEKEEPER_SCHEDULE || '*/30 * * * *', - timezone: process.env.CODEKEEPER_TIMEZONE || 'Asia/Shanghai', - tokenBudget: parseInt(process.env.CODEKEEPER_TOKEN_BUDGET || '200000', 10), - autoFix: parseBool(fields.AUTO_FIX, false), - autoFixBranchPrefix: 'codekeeper/fix-', - rulesFile: 'CLAUDE.md', - astGrepConfig: '.claude/rules/sgconfig.yml', - filter: { - excludeAuthors: ['bot', 'ci'], - excludeDrafts: true, - minChanges: 1, - maxChanges: 300, - excludePaths: ['**/*.md', 'docs/**'], - }, - }, - learning: { - enabled: parseBool(fields.LEARN_ENABLED, true), - schedule: '0 2 * * *', - patternThreshold: 3, - updateClaudeMd: true, - createAstGrepRules: true, - lookbackDays: 7, - }, - }; -} - -function extractHost(url: string): string { - try { - return new URL(url).host; - } catch { - return 'gitlab.com'; - } -} - -function resolveEnvValue(value: string): string { - if (!value) return ''; - if (value.startsWith('env:')) { - const varName = value.slice(4); - const envValue = process.env[varName]; - if (!envValue) { - logger.warn(`Environment variable ${varName} not found`); - return ''; - } - return envValue; - } - return value; -} - -function parseBool(value: string | undefined, defaultValue: boolean): boolean { - if (value === undefined) return defaultValue; - return value.toLowerCase() === 'true'; -} - -function capitalize(str: string): string { - return str.charAt(0).toUpperCase() + str.slice(1); -} - -/** - * Load from YAML config file (legacy support) - */ -function loadFileConfig(configPath?: string): { projects: ProjectConfig[] } | null { - const paths = [ - configPath, - process.env.CODEKEEPER_CONFIG, - path.join(process.cwd(), 'config', 'projects.yaml'), - path.join(process.cwd(), 'projects.yaml'), - path.join(getConfigDir(), 'projects.yaml'), - ].filter(Boolean) as string[]; - - for (const p of paths) { - if (fs.existsSync(p)) { - const content = fs.readFileSync(p, 'utf8'); - const parsed = YAML.parse(content); - const validated = ConfigSchema.parse(parsed); - return { - projects: validated.projects.map((p: ProjectConfig) => ({ - ...p, - localPath: path.resolve(p.localPath), - gitlab: { - ...p.gitlab, - token: resolveEnvValue(p.gitlab.token), - }, - })), - }; - } - } - - return null; -} - -export function validateProject(project: ProjectConfig): string[] { - const errors: string[] = []; - - if (!fs.existsSync(project.localPath)) { - errors.push(`localPath does not exist: ${project.localPath}`); - } else { - const gitDir = path.join(project.localPath, '.git'); - if (!fs.existsSync(gitDir)) { - errors.push(`Not a git repository: ${project.localPath}`); - } - } - - if (!project.gitlab.token) { - errors.push('GitLab token is empty (check env var resolution)'); - } - - if (project.review.tokenBudget < 10000) { - errors.push(`tokenBudget too low: ${project.review.tokenBudget}`); - } - - return errors; -} diff --git a/src/core/project-sync.ts b/src/core/project-sync.ts deleted file mode 100755 index 79bd81b..0000000 --- a/src/core/project-sync.ts +++ /dev/null @@ -1,148 +0,0 @@ -import simpleGit, { SimpleGit } from 'simple-git'; -import path from 'path'; -import { logger } from './logger.js'; -import type { ProjectConfig } from '../types.js'; - -export class ProjectSync { - private git: SimpleGit; - - constructor(private project: ProjectConfig) { - this.git = simpleGit(project.localPath); - } - - /** - * Fetch latest changes from remote - */ - async sync(): Promise { - logger.info(`[${this.project.id}] Syncing project...`); - - try { - await this.git.fetch(['origin', '--prune']); - logger.info(`[${this.project.id}] Fetch completed`); - } catch (err) { - logger.error({ err }, `[${this.project.id}] Fetch failed`); - throw err; - } - } - - /** - * Get diff between target branch and source branch - */ - async getDiff(targetBranch: string, sourceBranch: string): Promise { - const targetRef = `origin/${targetBranch}`; - const sourceRef = `origin/${sourceBranch}`; - - logger.debug(`[${this.project.id}] Getting diff: ${targetRef}...${sourceRef}`); - - try { - const diff = await this.git.diff([`${targetRef}...${sourceRef}`]); - return diff; - } catch { - // Fallback: try two-dot diff - logger.warn(`[${this.project.id}] Three-dot diff failed, trying two-dot`); - return await this.git.diff([`${targetRef}..${sourceRef}`]); - } - } - - /** - * Get list of changed files - */ - async getChangedFiles(targetBranch: string, sourceBranch: string): Promise { - const targetRef = `origin/${targetBranch}`; - const sourceRef = `origin/${sourceBranch}`; - - const summary = await this.git.diffSummary([`${targetRef}...${sourceRef}`]); - return summary.files.map(f => f.file); - } - - /** - * Create and checkout a new branch from source branch - */ - async createBranch(branchName: string, fromBranch: string): Promise { - logger.info(`[${this.project.id}] Creating branch ${branchName} from ${fromBranch}`); - - // Ensure we're on a clean state on the from branch - await this.git.fetch('origin', fromBranch); - - try { - await this.git.checkoutBranch(branchName, `origin/${fromBranch}`); - } catch { - // Branch might already exist locally - logger.warn(`[${this.project.id}] checkoutBranch failed, trying checkout existing`); - await this.git.checkout(branchName); - } - } - - /** - * Commit changes with message - */ - async commit(message: string): Promise { - await this.git.add('.'); - await this.git.commit(message); - } - - /** - * Push branch to remote - */ - async push(branchName: string, force = false): Promise { - const args = ['origin', branchName]; - if (force) args.unshift('--force'); - await this.git.push(args); - logger.info(`[${this.project.id}] Pushed ${branchName} to origin`); - } - - /** - * Get file content at path - */ - async readFile(filePath: string): Promise { - const fullPath = path.join(this.project.localPath, filePath); - const fs = await import('fs/promises'); - return fs.readFile(fullPath, 'utf8'); - } - - /** - * Write file content - */ - async writeFile(filePath: string, content: string): Promise { - const fullPath = path.join(this.project.localPath, filePath); - const fs = await import('fs/promises'); - await fs.mkdir(path.dirname(fullPath), { recursive: true }); - await fs.writeFile(fullPath, content, 'utf8'); - } - - /** - * Check if working directory is clean - */ - async isClean(): Promise { - const status = await this.git.status(); - return status.files.length === 0; - } - - /** - * Read project's CLAUDE.md rules - */ - async loadClaudeMd(): Promise { - const rulesPath = path.join(this.project.localPath, this.project.review.rulesFile); - const fs = await import('fs/promises'); - - try { - return await fs.readFile(rulesPath, 'utf8'); - } catch { - logger.warn(`[${this.project.id}] ${this.project.review.rulesFile} not found, using default rules`); - return this.getDefaultRules(); - } - } - - private getDefaultRules(): string { - return ` -# Default Review Rules - -## General -- Follow existing code style and patterns -- Avoid code duplication -- Use meaningful variable and function names -- Add error handling for async operations -- Keep functions focused and small -`; - } -} diff --git a/src/core/token-budget.ts b/src/core/token-budget.ts deleted file mode 100755 index daeff69..0000000 --- a/src/core/token-budget.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { MrDiff, DiffChunk, TokenBudget } from '../types.js'; -import { logger } from './logger.js'; - -// Token estimation constants (empirical, based on typical code density) -const TOKENS_PER_CHAR = 0.25; // ~4 chars per token for code -const SYSTEM_PROMPT_TOKENS = 1000; -const CLAUDE_MD_OVERHEAD = 5000; // Average CLAUDE.md size in tokens -const OUTPUT_RESERVE = 20000; // Reserve for AI response -const SAFETY_MARGIN = 0.8; // Use only 80% of budget to leave room - -/** - * Estimate token count for a string - */ -export function estimateTokens(text: string): number { - return Math.ceil(text.length * TOKENS_PER_CHAR); -} - -/** - * Calculate available token budget for diff review - */ -export function calculateDiffBudget(totalBudget: number): number { - const available = totalBudget - SYSTEM_PROMPT_TOKENS - CLAUDE_MD_OVERHEAD - OUTPUT_RESERVE; - return Math.floor(available * SAFETY_MARGIN); -} - -/** - * Parse unified diff into structured MrDiff entries - */ -export function parseDiff(unifiedDiff: string): MrDiff[] { - const files: MrDiff[] = []; - const lines = unifiedDiff.split('\n'); - - let currentFile: Partial | null = null; - let diffLines: string[] = []; - - for (const line of lines) { - // Detect file header: diff --git a/old b/new - const gitDiffMatch = line.match(/^diff --git a\/(.+) b\/(.+)$/); - if (gitDiffMatch) { - if (currentFile) { - finalizeFile(currentFile as MrDiff, diffLines); - files.push(currentFile as MrDiff); - } - currentFile = { - oldPath: gitDiffMatch[1], - newPath: gitDiffMatch[2], - filePath: gitDiffMatch[2], - diff: '', - additions: 0, - deletions: 0, - }; - diffLines = []; - continue; - } - - // Detect new file - if (line.startsWith('new file mode')) { - if (currentFile) currentFile.newFile = true; - continue; - } - - // Detect deleted file - if (line.startsWith('deleted file mode')) { - if (currentFile) currentFile.deletedFile = true; - continue; - } - - // Detect rename - if (line.startsWith('rename from')) { - if (currentFile) { - currentFile.oldPath = line.slice(12); - } - continue; - } - if (line.startsWith('rename to')) { - if (currentFile) { - currentFile.newPath = line.slice(10); - currentFile.filePath = line.slice(10); - } - continue; - } - - // Count additions/deletions - if (line.startsWith('+') && !line.startsWith('+++')) { - if (currentFile) currentFile.additions = (currentFile.additions || 0) + 1; - } - if (line.startsWith('-') && !line.startsWith('---')) { - if (currentFile) currentFile.deletions = (currentFile.deletions || 0) + 1; - } - - if (currentFile) { - diffLines.push(line); - } - } - - // Finalize last file - if (currentFile) { - finalizeFile(currentFile as MrDiff, diffLines); - files.push(currentFile as MrDiff); - } - - return files; -} - -function finalizeFile(file: MrDiff, lines: string[]): void { - file.diff = lines.join('\n'); - file.additions = file.additions || 0; - file.deletions = file.deletions || 0; -} - -/** - * Score file risk (higher = more important to review first) - */ -export function scoreFileRisk(filePath: string): number { - let score = 1; - - // Core/security files get higher priority - if (/\b(main|index|core|security|auth|crypt)\b/.test(filePath)) score += 5; - if (/\b(ipc|handler|router|controller)\b/.test(filePath)) score += 3; - if (/\b(test|spec)\b/.test(filePath)) score -= 2; - if (/\b(types|interface)\b/.test(filePath)) score += 1; - - // Configuration changes - if (/(config|settings|env)/.test(filePath)) score += 2; - - // Package changes - if (filePath.includes('package.json')) score += 4; - - return score; -} - -/** - * Split diffs into chunks that fit within token budget - */ -export function chunkDiffs(diffs: MrDiff[], budget: number): DiffChunk[] { - // Sort by risk score (high risk first) - const sorted = [...diffs].sort((a, b) => scoreFileRisk(b.filePath) - scoreFileRisk(a.filePath)); - - const chunks: DiffChunk[] = []; - let currentChunk: MrDiff[] = []; - let currentTokens = 0; - - for (const diff of sorted) { - const diffTokens = estimateTokens(diff.diff); - - if (currentTokens + diffTokens > budget && currentChunk.length > 0) { - // Finalize current chunk - chunks.push({ - files: currentChunk, - estimatedTokens: currentTokens, - index: chunks.length, - total: 0, // Will be set later - }); - currentChunk = []; - currentTokens = 0; - } - - currentChunk.push(diff); - currentTokens += diffTokens; - } - - // Don't forget the last chunk - if (currentChunk.length > 0) { - chunks.push({ - files: currentChunk, - estimatedTokens: currentTokens, - index: chunks.length, - total: 0, - }); - } - - // Set total count - const total = chunks.length; - for (const chunk of chunks) { - chunk.total = total; - } - - logger.info(`Split ${diffs.length} files into ${chunks.length} chunks (budget: ${budget} tokens)`); - for (const chunk of chunks) { - logger.debug(` Chunk ${chunk.index + 1}/${chunk.total}: ${chunk.files.length} files, ~${chunk.estimatedTokens} tokens`); - } - - return chunks; -} - -/** - * Create a TokenBudget tracker for a review session - */ -export function createBudgetTracker(total: number): TokenBudget { - return { - total, - used: 0, - remaining: total, - }; -} - -/** - * Record token usage - */ -export function recordUsage(budget: TokenBudget, used: number): void { - budget.used += used; - budget.remaining = budget.total - budget.used; - logger.debug(`Token usage: ${budget.used}/${budget.total} (${budget.remaining} remaining)`); -} diff --git a/src/electron/main/index.ts b/src/electron/main/index.ts index a94a35e..4783094 100755 --- a/src/electron/main/index.ts +++ b/src/electron/main/index.ts @@ -1,5 +1,5 @@ import { app, ipcMain, shell, BrowserWindow, dialog } from 'electron'; -import { createMainWindow } from './window-manager'; +import { createMainWindow, createLogsWindow } from './window-manager'; import { ElectronIpcClient } from './ipc-client'; import { loadTheme, saveTheme } from './theme-persistence.js'; import type { IpcPushEvent } from '../shared/types'; @@ -7,6 +7,7 @@ import type { IpcPushEvent } from '../shared/types'; const client = new ElectronIpcClient(); let mainWindow: BrowserWindow | null = null; let connected = false; +let logsWindow: BrowserWindow | null = null; async function connectWithRetry(maxAttempts = 30, intervalMs = 1000): Promise { for (let attempt = 1; attempt <= maxAttempts; attempt++) { @@ -22,7 +23,7 @@ async function connectWithRetry(maxAttempts = 30, intervalMs = 1000): Promise setTimeout(resolve, intervalMs)); + await new Promise(resolve => setTimeout(resolve, intervalMs)); } } } @@ -72,24 +73,38 @@ app.whenReady().then(async () => { return { success: true }; }); - ipcMain.handle('show-open-dialog', async (_event, options: { - title?: string; - defaultPath?: string; - properties?: string[]; - }) => { - const win = BrowserWindow.getFocusedWindow(); - const dialogOptions = { - title: options.title, - defaultPath: options.defaultPath, - properties: (options.properties ?? ['openDirectory']) as Array< - 'openFile' | 'openDirectory' | 'multiSelections' | 'showHiddenFiles' | 'createDirectory' | 'promptToCreate' | 'noResolveAliases' | 'treatPackageAsDirectory' | 'dontAddToRecent' - >, - }; - const result = win - ? await dialog.showOpenDialog(win, dialogOptions) - : await dialog.showOpenDialog(dialogOptions); - return result; - }); + ipcMain.handle( + 'show-open-dialog', + async ( + _event, + options: { + title?: string; + defaultPath?: string; + properties?: string[]; + } + ) => { + const win = BrowserWindow.getFocusedWindow(); + const dialogOptions = { + title: options.title, + defaultPath: options.defaultPath, + properties: (options.properties ?? ['openDirectory']) as Array< + | 'openFile' + | 'openDirectory' + | 'multiSelections' + | 'showHiddenFiles' + | 'createDirectory' + | 'promptToCreate' + | 'noResolveAliases' + | 'treatPackageAsDirectory' + | 'dontAddToRecent' + >, + }; + const result = win + ? await dialog.showOpenDialog(win, dialogOptions) + : await dialog.showOpenDialog(dialogOptions); + return result; + } + ); ipcMain.handle('window-minimize', () => { const win = BrowserWindow.getFocusedWindow(); @@ -111,6 +126,17 @@ app.whenReady().then(async () => { win?.close(); }); + ipcMain.handle('open-logs-window', () => { + if (logsWindow && !logsWindow.isDestroyed()) { + logsWindow.focus(); + return; + } + logsWindow = createLogsWindow(); + logsWindow.on('closed', () => { + logsWindow = null; + }); + }); + function sendWindowState(win: BrowserWindow, isMaximized: boolean): void { win.webContents.send('window-state-change', { isMaximized }); } diff --git a/src/electron/main/ipc-client.ts b/src/electron/main/ipc-client.ts index c6e867c..8b5f59f 100755 --- a/src/electron/main/ipc-client.ts +++ b/src/electron/main/ipc-client.ts @@ -21,8 +21,8 @@ export class ElectronIpcClient { this.socket = createConnection(getIpcSocketPath(), () => { resolve(); }); - this.socket.on('data', (data) => this.handleData(data)); - this.socket.on('error', (err) => { + this.socket.on('data', data => this.handleData(data)); + this.socket.on('error', err => { // 连接阶段的错误交给 connect Promise if (!this.isConnected()) { reject(err); diff --git a/src/electron/main/window-manager.ts b/src/electron/main/window-manager.ts index 4137934..010ec78 100755 --- a/src/electron/main/window-manager.ts +++ b/src/electron/main/window-manager.ts @@ -41,3 +41,30 @@ export function createMainWindow(): BrowserWindow { return win; } + +/** 独立日志窗口:复用渲染 bundle,但不显示主应用侧边栏。 */ +export function createLogsWindow(): BrowserWindow { + const win = new BrowserWindow({ + width: 1100, + height: 760, + title: 'CodeKeeper 日志', + icon: getIconPath(), + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false, + }, + }); + win.setMenu(null); + + const loadTarget = + process.env.VITE_DEV_SERVER_URL || (!app.isPackaged && 'http://localhost:5173'); + if (loadTarget) { + const url = new URL(loadTarget); + url.searchParams.set('window', 'logs'); + void win.loadURL(url.toString()); + } else { + void win.loadFile(join(__dirname, '../renderer/index.html'), { search: 'window=logs' }); + } + return win; +} diff --git a/src/electron/preload/index.ts b/src/electron/preload/index.ts index 672a5f8..33b1b07 100755 --- a/src/electron/preload/index.ts +++ b/src/electron/preload/index.ts @@ -14,6 +14,7 @@ contextBridge.exposeInMainWorld('electronAPI', { windowMinimize: () => ipcRenderer.invoke('window-minimize'), windowMaximize: () => ipcRenderer.invoke('window-maximize'), windowClose: () => ipcRenderer.invoke('window-close'), + openLogsWindow: () => ipcRenderer.invoke('open-logs-window'), onWindowStateChange: (callback: (state: { isMaximized: boolean }) => void) => { const handler = (_event: unknown, data: { isMaximized: boolean }) => callback(data); ipcRenderer.on('window-state-change', handler); diff --git a/src/electron/renderer/App.tsx b/src/electron/renderer/App.tsx index 9269ec7..71e7037 100755 --- a/src/electron/renderer/App.tsx +++ b/src/electron/renderer/App.tsx @@ -10,25 +10,14 @@ import { DashboardIcon, MemoryGraphIcon, MemoryStatsIcon, - HistoryIcon, - LogsIcon, SettingsIcon, } from './components/icons.js'; import { Dashboard } from './pages/Dashboard.js'; import { ProjectDetail } from './pages/ProjectDetail.js'; -import { ActionHistory } from './pages/ActionHistory.js'; -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'; +import { KnowledgeHubPage } from './pages/KnowledgeHubPage.js'; +import { SystemStatusPage } from './pages/SystemStatusPage.js'; +import { Logs } from './pages/Logs.js'; interface NavItem { to: string; @@ -36,19 +25,15 @@ interface NavItem { icon: React.ComponentType; } -/** 位于角色导航之前的顶部功能入口 */ +/** 全局一级导航:项目、智库与运行基础设施 */ const TOP_NAV_ITEMS: NavItem[] = [ { to: '/', label: '仪表盘', icon: DashboardIcon }, - { to: '/memory', label: '记忆图谱', icon: MemoryGraphIcon }, - { to: '/memory-stats', label: '记忆统计', icon: MemoryStatsIcon }, + { to: '/knowledge', label: '智库', icon: MemoryGraphIcon }, + { to: '/system', label: '系统状态', icon: MemoryStatsIcon }, ]; -/** 位于角色导航之后的底部功能入口 */ -const BOTTOM_NAV_ITEMS: NavItem[] = [ - { to: '/history', label: '动作历史', icon: HistoryIcon }, - { to: '/logs', label: '日志', icon: LogsIcon }, - { to: '/settings', label: '设置', icon: SettingsIcon }, -]; +/** 底部配置入口 */ +const BOTTOM_NAV_ITEMS: NavItem[] = [{ to: '/settings', label: '设置', icon: SettingsIcon }]; /** 简单 Toast 组件 */ function Toast({ message, onClose }: { message: string; onClose: () => void }) { @@ -63,6 +48,16 @@ function Toast({ message, onClose }: { message: string; onClose: () => void }) { } export function App() { + const isLogsWindow = new URLSearchParams(window.location.search).get('window') === 'logs'; + if (isLogsWindow) { + return ( + + + + + + ); + } return ( @@ -114,7 +109,7 @@ function ReadyGuard({ isReady, children }: { isReady: boolean; children: React.R function AppContent() { const location = useLocation(); const { sidebarCollapsed } = useLayout(); - const isMemoryRoute = location.pathname === '/memory'; + const isMemoryRoute = location.pathname === '/knowledge'; const { daemon, localModel, loading } = useServiceStatus(); const isReady = @@ -150,17 +145,6 @@ function AppContent() { ))} - {getAllRoleUIs().map(ui => { - const Icon = ui.icon; - return ( - - ); - })} - {BOTTOM_NAV_ITEMS.map(item => ( ))} @@ -179,63 +163,30 @@ function AppContent() { } /> - - - - } - /> - - - - } - /> } /> - - - } - /> - - - - } - /> - - - - } - /> - - + } /> - + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/src/electron/renderer/api/electron-api.ts b/src/electron/renderer/api/electron-api.ts index 6f58ba3..c58fe18 100755 --- a/src/electron/renderer/api/electron-api.ts +++ b/src/electron/renderer/api/electron-api.ts @@ -14,6 +14,7 @@ export interface ElectronAPI { windowMinimize(): Promise; windowMaximize(): Promise; windowClose(): Promise; + openLogsWindow(): Promise; onWindowStateChange(callback: (state: { isMaximized: boolean }) => void): () => void; } @@ -56,7 +57,9 @@ export function windowClose(): Promise { return getAPI().windowClose(); } -export function onWindowStateChange(callback: (state: { isMaximized: boolean }) => void): () => void { +export function onWindowStateChange( + callback: (state: { isMaximized: boolean }) => void +): () => void { return getAPI().onWindowStateChange(callback); } diff --git a/src/electron/renderer/components/ArchiveTree.tsx b/src/electron/renderer/components/ArchiveTree.tsx index b8fc25a..84e9f7e 100755 --- a/src/electron/renderer/components/ArchiveTree.tsx +++ b/src/electron/renderer/components/ArchiveTree.tsx @@ -22,7 +22,7 @@ function TreeNode({ node, depth = 0 }: { node: FileTreeNode; depth?: number }) { {isDir && node.children && node.children.length > 0 && (
- {node.children.map((child) => ( + {node.children.map(child => ( ))}
diff --git a/src/electron/renderer/components/ArchiverProviderConfig.tsx b/src/electron/renderer/components/ArchiverProviderConfig.tsx index 1acba28..c77e15c 100644 --- a/src/electron/renderer/components/ArchiverProviderConfig.tsx +++ b/src/electron/renderer/components/ArchiverProviderConfig.tsx @@ -222,8 +222,8 @@ export function ArchiverProviderConfig({ project, onSaved }: ArchiverProviderCon if (loading) { return ( -
-
加载项目知识配置...
+
+
加载项目知识配置...
); } @@ -235,22 +235,22 @@ export function ArchiverProviderConfig({ project, onSaved }: ArchiverProviderCon ]; return ( -
- {error &&
{error}
} - {saved &&
项目知识配置已保存。
} +
+ {error &&
{error}
} + {saved &&
项目知识配置已保存。
} -
-
+
+

Archiver 身份

该名称会作为 Archiver Role 写入 EverOS 的 Agent 身份。

-
- +
+ setConfig(previous => ({ @@ -262,20 +262,20 @@ export function ArchiverProviderConfig({ project, onSaved }: ArchiverProviderCon />
{nameChanged && ( -
+
修改名称会创建新的 EverOS Agent 身份,旧记忆不会自动迁移。
)}
-
-
+
+

自动归档

只需选择更新频率;Provider 的安装、启动和回退由系统自动处理。

-
+
更新频率 系统会自动选择可用知识源,并持续整理代码与文档知识。 @@ -284,15 +284,15 @@ export function ArchiverProviderConfig({ project, onSaved }: ArchiverProviderCon value={scheduleMode} options={scheduleOptions} onChange={changeSchedule} - className='archiver-v2-dropdown' + className="archiver-v2-dropdown" />
{scheduleMode === 'custom' && ( -
- +
+ setConfig(previous => ({ @@ -300,48 +300,48 @@ export function ArchiverProviderConfig({ project, onSaved }: ArchiverProviderCon automation: { ...previous.automation, cron: event.target.value }, })) } - placeholder='0 2 * * *' + placeholder="0 2 * * *" />
)}
-
-
+
+

自动知识方案

无需选择 Provider。系统按内置优先级探测环境,自动完成主源、回退和文档知识提炼。

-
+
{selectedProvider ? `当前主知识源:${selectedProvider.displayName}` : '系统自动遴选知识源'} - 默认优先结构探索 Provider;不可用时自动回退,并始终保留内置文档知识提炼。交互式 - Skill 只自动准备资源,由 Agent 工作流执行。 + 默认优先结构探索 Provider;不可用时自动回退,并始终保留内置文档知识提炼。交互式 Skill + 只自动准备资源,由 Agent 工作流执行。
-
-
+
+

Provider 诊断

仅展示系统自动探测结果,不显示启动命令、参数、环境变量或路径。

-
+
{providers.map(provider => { const probe = probes[provider.id]; const runStatus = latestStatuses.get(provider.id); @@ -354,8 +354,8 @@ export function ArchiverProviderConfig({ project, onSaved }: ArchiverProviderCon ? 'badge-warning' : 'badge-info'; return ( -
-
+
+
{provider.displayName} {providerKindLabel(provider.kind)} @@ -365,18 +365,18 @@ export function ArchiverProviderConfig({ project, onSaved }: ArchiverProviderCon

{provider.description}

-
+
版本:{probe?.version ?? '—'} 最近状态:{statusLabel(runStatus)} {runStatus?.finishedAt && {formatTime(runStatus.finishedAt)}}
{probe?.message && ( - {probe.message} + {probe.message} )} {provider.homepage && (
-
+
- {saved && 已保存} + {saved && 已保存}
); diff --git a/src/electron/renderer/components/AutocompleteInput.tsx b/src/electron/renderer/components/AutocompleteInput.tsx index 41b4e33..11a79f1 100755 --- a/src/electron/renderer/components/AutocompleteInput.tsx +++ b/src/electron/renderer/components/AutocompleteInput.tsx @@ -27,9 +27,7 @@ export function AutocompleteInput({ const filtered = useMemo(() => { const raw = value.trim().toLowerCase(); if (!raw) return options.slice(0, 10); - return options - .filter((o) => o.toLowerCase().includes(raw)) - .slice(0, 10); + return options.filter(o => o.toLowerCase().includes(raw)).slice(0, 10); }, [value, options]); useEffect(() => { @@ -52,11 +50,11 @@ export function AutocompleteInput({ switch (e.key) { case 'ArrowDown': e.preventDefault(); - setHighlighted((prev) => (prev + 1) % filtered.length); + setHighlighted(prev => (prev + 1) % filtered.length); break; case 'ArrowUp': e.preventDefault(); - setHighlighted((prev) => (prev - 1 + filtered.length) % filtered.length); + setHighlighted(prev => (prev - 1 + filtered.length) % filtered.length); break; case 'Enter': e.preventDefault(); @@ -82,16 +80,14 @@ export function AutocompleteInput({ className="input autocomplete-input" value={value} placeholder={placeholder} - onChange={(e) => { + onChange={e => { onChange(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} onKeyDown={handleKeyDown} /> - {loading && open && ( -
加载中...
- )} + {loading && open &&
加载中...
} {open && !loading && filtered.length > 0 && (
{filtered.map((option, index) => ( diff --git a/src/electron/renderer/components/CircularProgress.tsx b/src/electron/renderer/components/CircularProgress.tsx index b22ec21..db45904 100755 --- a/src/electron/renderer/components/CircularProgress.tsx +++ b/src/electron/renderer/components/CircularProgress.tsx @@ -7,7 +7,8 @@ interface CircularProgressProps { export function CircularProgress({ value, size = 16, strokeWidth = 2 }: CircularProgressProps) { const radius = (size - strokeWidth) / 2; const circumference = 2 * Math.PI * radius; - const offset = value != null ? circumference - (value / 100) * circumference : circumference * 0.75; + const offset = + value != null ? circumference - (value / 100) * circumference : circumference * 0.75; return ( diff --git a/src/electron/renderer/components/CollapsibleSection.tsx b/src/electron/renderer/components/CollapsibleSection.tsx index 9a794c3..3840dc5 100755 --- a/src/electron/renderer/components/CollapsibleSection.tsx +++ b/src/electron/renderer/components/CollapsibleSection.tsx @@ -42,12 +42,14 @@ export function CollapsibleSection({ if (isControlled) { onToggle?.(); } else { - setInternalExpanded((prev) => !prev); + setInternalExpanded(prev => !prev); } }; return ( -
+
{open && (
- {options.map((option) => ( + {options.map(option => (
= [ + { role: 'reviewer', label: 'Reviewer' }, + { role: 'maintainer', label: 'Maintainer' }, + { role: 'archiver', label: 'Archiver' }, +]; + +/** Dashboard 全局角色服务控制区。角色是跨项目运行时,项目配置仍留在管线节点检查器。 */ +export function GlobalRoleServices() { + const [statuses, setStatuses] = useState>>({}); + const [busyRoles, setBusyRoles] = useState>(() => new Set()); + const [actionError, setActionError] = useState(null); + const requestId = useRef(0); + const mounted = useRef(true); + const busyRolesRef = useRef>(new Set()); + + const refresh = useCallback(async () => { + const currentRequest = ++requestId.current; + const entries = await Promise.all( + ROLES.map(async ({ role }) => { + try { + const status = await invoke('role.service.status', { role }); + return [role, { status }] as const; + } catch (error) { + return [role, { error: error instanceof Error ? error.message : String(error) }] as const; + } + }) + ); + if (currentRequest !== requestId.current) return; + setStatuses(previous => { + const next = { ...previous }; + for (const [role, state] of entries) next[role] = state; + return next; + }); + }, []); + + useEffect(() => { + mounted.current = true; + let timer: ReturnType | undefined; + const scheduleRefresh = () => { + if (!mounted.current || document.visibilityState === 'hidden') return; + timer = setTimeout(async () => { + await refresh(); + scheduleRefresh(); + }, 3000); + }; + void refresh(); + scheduleRefresh(); + const handleVisibility = () => { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + if (document.visibilityState === 'visible') void refresh(); + scheduleRefresh(); + }; + document.addEventListener('visibilitychange', handleVisibility); + return () => { + mounted.current = false; + requestId.current += 1; + if (timer) clearTimeout(timer); + document.removeEventListener('visibilitychange', handleVisibility); + }; + }, [refresh]); + + const changeService = async (role: Role, action: 'start' | 'stop' | 'restart') => { + if (busyRolesRef.current.has(role)) return; + busyRolesRef.current.add(role); + setBusyRoles(previous => new Set(previous).add(role)); + setActionError(null); + try { + await invoke(`role.service.${action}`, { role }); + await refresh(); + } catch (error) { + if (mounted.current) { + setActionError( + `${labelForRole(role)}${actionLabel(action)}失败:${error instanceof Error ? error.message : String(error)}` + ); + } + } finally { + busyRolesRef.current.delete(role); + if (mounted.current) { + setBusyRoles(previous => { + const next = new Set(previous); + next.delete(role); + return next; + }); + } + } + }; + + return ( +
+
+
+

全局角色服务

+

控制跨项目运行的 Role 节点。项目级启用状态在各项目管线中管理。

+
+ +
+
+ {ROLES.map(({ role, label }) => { + const status = statuses[role]; + const unavailable = Boolean(status?.error); + const busy = busyRoles.has(role); + return ( +
+
+ {label} + + + {unavailable ? '状态不可用' : status?.status?.running ? '运行中' : '已停止'} + +
+
+ {unavailable + ? (status?.error ?? '状态查询失败') + : `${status?.status?.enabledProjects ?? 0} 个项目可运行`} +
+
+ {status?.status?.running ? ( + <> + + + + ) : ( + + )} +
+
+ ); + })} +
+ {actionError &&
{actionError}
} +
+ ); +} + +function labelForRole(role: Role): string { + return ROLES.find(item => item.role === role)?.label ?? role; +} + +function actionLabel(action: 'start' | 'stop' | 'restart'): string { + return action === 'start' ? '启动' : action === 'stop' ? '停止' : '重启'; +} diff --git a/src/electron/renderer/components/KnowledgePanel.tsx b/src/electron/renderer/components/KnowledgePanel.tsx new file mode 100644 index 0000000..2ce9e59 --- /dev/null +++ b/src/electron/renderer/components/KnowledgePanel.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; +import { useIpc } from '../hooks/useIpc.js'; +import { invoke } from '../api/electron-api.js'; + +interface KnowledgeItemDto { + id: string; + title: string; + category: string; + scope: string; + confidence: string; + source: string; + tags: string[]; + updated: string; + root: 'shared' | 'local'; + bodyPreview: string; +} + +interface KnowledgeListResult { + items: KnowledgeItemDto[]; + inbox: KnowledgeItemDto[]; +} + +const SOURCE_LABELS: Record = { + human: '人工', + distilled: '蒸馏', + archiver: '归档', +}; + +/** + * 项目智库面板:策展正本(共享+本地)与蒸馏草稿箱(人审闸门)。 + */ +export function KnowledgePanel({ projectId }: { projectId: string }) { + const { data, refresh } = useIpc('knowledge.list', { projectId }); + const [busy, setBusy] = useState(null); + + const act = async (method: 'knowledge.approve' | 'knowledge.dismiss', knowledgeId: string) => { + setBusy(knowledgeId); + try { + await invoke(method, { projectId, knowledgeId }); + refresh(); + } finally { + setBusy(null); + } + }; + + if (!data) return
加载智库…
; + + return ( +
+ {data.inbox.length > 0 && ( +
+

待审草稿({data.inbox.length})

+

蒸馏管线产出的候选,批准后进入共享正本(随项目入库)。

+ {data.inbox.map(item => ( +
+
+ {item.title} + + {item.category} · {SOURCE_LABELS[item.source] ?? item.source} · 置信{' '} + {item.confidence} + +
+

{item.bodyPreview}

+
+ + +
+
+ ))} +
+ )} + +
+

策展正本({data.items.length})

+ {data.items.length === 0 && ( +

+ 尚无策展知识。正本位于项目的 .codekeeper/knowledge/(Markdown + frontmatter)。 +

+ )} + {data.items.map(item => ( +
+
+ {item.title} + + {item.category} · {SOURCE_LABELS[item.source] ?? item.source} ·{' '} + {item.root === 'shared' ? '共享' : '私有'} · {item.updated} + +
+ {item.tags.length > 0 && ( +
+ {item.tags.map(tag => ( + + {tag} + + ))} +
+ )} +

{item.bodyPreview}

+
+ ))} +
+
+ ); +} diff --git a/src/electron/renderer/components/MemoryGraph.tsx b/src/electron/renderer/components/MemoryGraph.tsx index 1add292..b827f49 100755 --- a/src/electron/renderer/components/MemoryGraph.tsx +++ b/src/electron/renderer/components/MemoryGraph.tsx @@ -4,7 +4,17 @@ import { DataSet } from 'vis-data'; import { useTheme } from '../contexts/ThemeContext.js'; import type { MemoryGraph, MemoryGraphEdge, MemoryGraphNode } from '../../shared/types.js'; -const GROUP_KEYS = ['system', 'project', 'topic', 'episode', 'agent_case', 'agent_skill', 'profile', 'agent', 'user'] as const; +const GROUP_KEYS = [ + 'system', + 'project', + 'topic', + 'episode', + 'agent_case', + 'agent_skill', + 'profile', + 'agent', + 'user', +] as const; type GroupKey = (typeof GROUP_KEYS)[number]; const GROUP_LABELS: Record = { @@ -44,8 +54,8 @@ function edgeKey(e: MemoryGraphEdge): string { function graphsEqual(a: MemoryGraph, b: MemoryGraph): boolean { if (a.nodes.length !== b.nodes.length || a.edges.length !== b.edges.length) return false; - const aNodeIds = a.nodes.map((n) => n.id).sort(); - const bNodeIds = b.nodes.map((n) => n.id).sort(); + const aNodeIds = a.nodes.map(n => n.id).sort(); + const bNodeIds = b.nodes.map(n => n.id).sort(); for (let i = 0; i < aNodeIds.length; i++) { if (aNodeIds[i] !== bNodeIds[i]) return false; } @@ -76,8 +86,12 @@ export function MemoryGraph({ graph, onNodeSelect }: MemoryGraphProps) { graphRef.current = graph; return graph; }, [graph]); - const [activeGroups, setActiveGroups] = useState>(() => new Set(Object.keys(GROUP_LABELS))); - const [tooltip, setTooltip] = useState<{ x: number; y: number; node?: MemoryGraphNode } | null>(null); + const [activeGroups, setActiveGroups] = useState>( + () => new Set(Object.keys(GROUP_LABELS)) + ); + const [tooltip, setTooltip] = useState<{ x: number; y: number; node?: MemoryGraphNode } | null>( + null + ); useEffect(() => { if (!containerRef.current) return; @@ -89,14 +103,16 @@ export function MemoryGraph({ graph, onNodeSelect }: MemoryGraphProps) { const edgeHighlight = style.getPropertyValue('--graph-edge-highlight').trim() || '#2563eb'; const nodes = new DataSet( - stableGraph.nodes.map((n) => ({ + stableGraph.nodes.map(n => ({ ...n, color: colors[n.group as GroupKey], hidden: !activeGroups.has(n.group), })) ); nodesRef.current = nodes; - const edges = new DataSet(stableGraph.edges.map((e, idx) => ({ ...e, id: e.id ?? `edge-${idx}` }))); + const edges = new DataSet( + stableGraph.edges.map((e, idx) => ({ ...e, id: e.id ?? `edge-${idx}` })) + ); const network = new Network( containerRef.current, @@ -105,7 +121,11 @@ export function MemoryGraph({ graph, onNodeSelect }: MemoryGraphProps) { nodes: { shape: 'dot', size: 18, - font: { color: textPrimary, size: 13, face: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif' }, + font: { + color: textPrimary, + size: 13, + face: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif', + }, borderWidth: 2, shadow: { enabled: true, color: 'rgba(0,0,0,0.5)', size: 10, x: 0, y: 0 }, }, @@ -129,16 +149,16 @@ export function MemoryGraph({ graph, onNodeSelect }: MemoryGraphProps) { } ); - network.on('click', (params) => { + network.on('click', params => { if (params.nodes.length > 0 && onNodeSelect) { const nodeId = params.nodes[0] as string; - const node = stableGraph.nodes.find((n) => n.id === nodeId); + const node = stableGraph.nodes.find(n => n.id === nodeId); if (node) onNodeSelect(node); } }); - network.on('hoverNode', (params) => { - const node = stableGraph.nodes.find((n) => n.id === params.node); + network.on('hoverNode', params => { + const node = stableGraph.nodes.find(n => n.id === params.node); if (!node) return; const domCoords = network.canvasToDOM(network.getPositions([params.node])[params.node]); const rect = containerRef.current!.getBoundingClientRect(); @@ -185,7 +205,7 @@ export function MemoryGraph({ graph, onNodeSelect }: MemoryGraphProps) { }, [theme]); const toggleGroup = (group: string) => { - setActiveGroups((prev) => { + setActiveGroups(prev => { const next = new Set(prev); if (next.has(group)) next.delete(group); else next.add(group); @@ -193,8 +213,10 @@ export function MemoryGraph({ graph, onNodeSelect }: MemoryGraphProps) { }); }; - const zoomIn = () => networkRef.current?.moveTo({ scale: (networkRef.current.getScale() ?? 1) * 1.2 }); - const zoomOut = () => networkRef.current?.moveTo({ scale: (networkRef.current.getScale() ?? 1) / 1.2 }); + const zoomIn = () => + networkRef.current?.moveTo({ scale: (networkRef.current.getScale() ?? 1) * 1.2 }); + const zoomOut = () => + networkRef.current?.moveTo({ scale: (networkRef.current.getScale() ?? 1) / 1.2 }); const fit = () => networkRef.current?.fit({ animation: true }); const colors = readGraphColors(); @@ -224,26 +246,35 @@ export function MemoryGraph({ graph, onNodeSelect }: MemoryGraphProps) {
{Object.entries(GROUP_LABELS).map(([group, label]) => (
- + {label}
))}
- - - + + +
{tooltip?.node && ( -
+
{GROUP_LABELS[tooltip.node.group]} diff --git a/src/electron/renderer/components/MemoryNodePanel.tsx b/src/electron/renderer/components/MemoryNodePanel.tsx index f639558..0895ce8 100755 --- a/src/electron/renderer/components/MemoryNodePanel.tsx +++ b/src/electron/renderer/components/MemoryNodePanel.tsx @@ -38,22 +38,30 @@ const GROUP_LABELS: Record = { export function MemoryNodePanel({ node, graph, onClose, onNodeClick }: MemoryNodePanelProps) { const connections = useMemo(() => { return graph.edges - .filter((e) => e.from === node.id || e.to === node.id) - .map((e) => { + .filter(e => e.from === node.id || e.to === node.id) + .map(e => { const otherId = e.from === node.id ? e.to : e.from; - const other = graph.nodes.find((n) => n.id === otherId); - return { edge: e, other: other ?? { id: otherId, label: otherId, group: 'system' as const } }; + const other = graph.nodes.find(n => n.id === otherId); + return { + edge: e, + other: other ?? { id: otherId, label: otherId, group: 'system' as const }, + }; }); }, [node, graph]); return (
- +
{GROUP_LABELS[node.group] ?? node.group} @@ -113,7 +121,9 @@ export function MemoryNodePanel({ node, graph, onClose, onNodeClick }: MemoryNod style={{ backgroundColor: GROUP_COLORS[other.group] ?? '#8b949e' }} /> {other.label} - {edge.label && {edge.label}} + {edge.label && ( + {edge.label} + )} ))}
diff --git a/src/electron/renderer/components/MemoryProgressView.tsx b/src/electron/renderer/components/MemoryProgressView.tsx index aa3af48..2bfb0f1 100755 --- a/src/electron/renderer/components/MemoryProgressView.tsx +++ b/src/electron/renderer/components/MemoryProgressView.tsx @@ -10,7 +10,7 @@ interface MemoryProgressViewProps { * Progress View 页签:统计、增长图、时间线(EverOS 风格) */ export function MemoryProgressView({ stats, graph }: MemoryProgressViewProps) { - const maxGrowth = Math.max(1, ...stats.dailyGrowth.map((d) => d.count)); + const maxGrowth = Math.max(1, ...stats.dailyGrowth.map(d => d.count)); const timeline = useMemo(() => { const groups = new Map(); @@ -37,7 +37,9 @@ export function MemoryProgressView({ stats, graph }: MemoryProgressViewProps) {
Total Memories
{stats.totalMemories}
-
+{stats.dailyGrowth.slice(-1)[0]?.count ?? 0} today
+
+ +{stats.dailyGrowth.slice(-1)[0]?.count ?? 0} today +
Connections
@@ -59,7 +61,7 @@ export function MemoryProgressView({ stats, graph }: MemoryProgressViewProps) {
Memory Growth (Last 14 Days)
- {stats.dailyGrowth.map((d) => ( + {stats.dailyGrowth.map(d => (
{day.count} memories
- {day.nodes.map((node) => ( + {day.nodes.map(node => (
{node.label}
- {node.details &&
{node.details.slice(0, 120)}
} + {node.details && ( +
+ {node.details.slice(0, 120)} +
+ )}
))}
diff --git a/src/electron/renderer/components/PageHeader.tsx b/src/electron/renderer/components/PageHeader.tsx index 240a430..f051658 100755 --- a/src/electron/renderer/components/PageHeader.tsx +++ b/src/electron/renderer/components/PageHeader.tsx @@ -20,9 +20,7 @@ export function PageHeader({ icon, title, onRefresh, extra }: PageHeaderProps) { const { sidebarCollapsed } = useLayout(); return ( -
+
{icon}

{title}

@@ -31,12 +29,7 @@ export function PageHeader({ icon, title, onRefresh, extra }: PageHeaderProps) {
{extra} {onRefresh && ( - )} diff --git a/src/electron/renderer/components/PipelineCanvas.tsx b/src/electron/renderer/components/PipelineCanvas.tsx new file mode 100644 index 0000000..772ab76 --- /dev/null +++ b/src/electron/renderer/components/PipelineCanvas.tsx @@ -0,0 +1,353 @@ +import { useCallback, useRef, useState } from 'react'; +import type { + PipelineDefinitionDto, + PipelineGetResult, + PipelineNodeDto, + PipelineRunDto, + Project, +} from '../../shared/types.js'; +import { useIpc } from '../hooks/useIpc.js'; +import { invoke } from '../api/electron-api.js'; +import { PipelineGraph } from './PipelineGraph.js'; +import { RoleProjectConfig } from './RoleProjectConfig.js'; +import { ArchiverProviderConfig } from './ArchiverProviderConfig.js'; +import { + addEdgeToDefinition, + addNodeToDefinition, + deleteEdgesFromDefinition, + deleteNodesFromDefinition, + getDrillDefinition, + materializeRoleSubgraph, + moveAllNodesInDefinition, + NODE_PALETTE, + updateDrillDefinition, + updateNodeInDefinition, +} from './pipeline-edit.js'; + +/** 草稿缓存:tab 切换/组件卸载时保留未保存编辑(按项目隔离) */ +const draftCache = new Map(); + +interface PipelineCanvasProps { + projectId: string; +} + +/** + * 项目管线画布页:渲染/编辑 pipeline.yaml,叠加运行状态。 + * 保存即写回 YAML 并热加载;保存后正本转为人类正本(不再随角色配置自动重建)。 + */ +export function PipelineCanvas({ projectId }: PipelineCanvasProps) { + const { data: pipeline, refresh } = useIpc('pipeline.get', { projectId }); + const { data: project } = useIpc('project.get', { projectId }); + const { data: runs } = useIpc( + 'pipeline.runs', + { projectId, limit: 10 }, + { pollInterval: 5000 } + ); + + const [draft, setDraftState] = useState( + () => draftCache.get(projectId) ?? null + ); + // 草稿写入模块级缓存,组件重挂载(tab 切换)后可恢复 + const setDraft = ( + value: + | PipelineDefinitionDto + | null + | ((prev: PipelineDefinitionDto | null) => PipelineDefinitionDto | null) + ) => { + setDraftState(prev => { + const next = typeof value === 'function' ? value(prev) : value; + if (next) draftCache.set(projectId, next); + else draftCache.delete(projectId); + return next; + }); + }; + const [selectedNodeId, setSelectedNodeId] = useState(null); + // 钻取路径(M7):非空时画布显示顶层节点的子图 + const [drillPath, setDrillPathState] = useState([]); + const drillPathRef = useRef([]); + drillPathRef.current = drillPath; + const setDrillPath = (path: string[]) => { + drillPathRef.current = path; + setDrillPathState(path); + setSelectedNodeId(null); + }; + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + // 编辑态:有未保存草稿时用草稿,否则用正本 + const pipelineDefRef = useRef(null); + pipelineDefRef.current = pipeline?.definition ?? null; + const definition = draft ?? pipelineDefRef.current; + const dirty = draft !== null; + const viewDefinition = definition + ? (getDrillDefinition(definition, drillPath) ?? definition) + : null; + + const mutate = useCallback((fn: (def: PipelineDefinitionDto) => PipelineDefinitionDto) => { + setDraft(prev => { + const base = prev ?? pipelineDefRef.current; + if (!base) return prev; + // 钻取态下编辑作用于当前子图,再映射回根定义(drillPath 经 ref 读取,避免闭包过期) + const path = drillPathRef.current; + return path.length === 0 + ? fn(base) + : updateDrillDefinition(base, path, fn(getDrillDefinition(base, path) ?? base)); + }); + }, []); + + const handleSelectNode = useCallback((nodeId: string | null) => setSelectedNodeId(nodeId), []); + + const handleMoveAllNodes = useCallback( + (positions: Record) => { + mutate(def => moveAllNodesInDefinition(def, positions)); + }, + [mutate] + ); + + const handleAddEdge = useCallback( + (fromNodeId: string, toNodeId: string) => { + mutate(def => addEdgeToDefinition(def, fromNodeId, toNodeId)); + }, + [mutate] + ); + + const handleDeleteNodes = useCallback( + (nodeIds: string[]) => { + mutate(def => deleteNodesFromDefinition(def, nodeIds)); + setSelectedNodeId(null); + }, + [mutate] + ); + + const handleDeleteEdges = useCallback( + (edgeIds: string[]) => { + mutate(def => deleteEdgesFromDefinition(def, edgeIds)); + }, + [mutate] + ); + + const addNode = useCallback( + (type: string, label: string) => { + mutate(def => addNodeToDefinition(def, type, label)); + }, + [mutate] + ); + + const save = useCallback(async () => { + if (!draft) return; + setSaving(true); + setSaveError(null); + try { + await invoke('pipeline.update', { projectId, definition: draft }); + setDraft(null); + refresh(); + } catch (error) { + setSaveError(error instanceof Error ? error.message : String(error)); + } finally { + setSaving(false); + } + }, [draft, projectId, refresh]); + + const discard = useCallback(() => { + setDraft(null); + setSaveError(null); + setDrillPath([]); + }, []); + + if (!pipeline) return
加载管线定义…
; + if (!definition) { + return
项目暂无可调度的角色,请先在角色页启用角色。
; + } + + const selectedNode = viewDefinition?.nodes.find(node => node.id === selectedNodeId) ?? null; + + return ( +
+
+ {NODE_PALETTE.map(item => ( + + ))} + 编辑模式下拖出连线;选中后按 Delete 删除 + + {pipeline.generated && !dirty && 自动生成} + {dirty && 未保存} + + {dirty && ( + + )} +
+ {saveError &&
保存失败:{saveError}
} +
+ {drillPath.length > 0 && ( +
+ + 钻取:{drillPath.join(' / ')} +
+ )} + + {selectedNode && ( + { + mutate(def => updateNodeInDefinition(def, updated)); + }} + onConfigSaved={refresh} + onDrillIn={nodeId => setDrillPath([...drillPathRef.current, nodeId])} + onMaterializeSubgraph={node => { + mutate(root => { + const path = drillPathRef.current; + const apply = (def: PipelineDefinitionDto): PipelineDefinitionDto => ({ + ...def, + nodes: def.nodes.map(n => + n.id === node.id ? { ...n, subgraph: materializeRoleSubgraph(n.id, n.type) } : n + ), + }); + return path.length === 0 + ? apply(root) + : updateDrillDefinition( + root, + path, + apply(getDrillDefinition(root, path) ?? root) + ); + }); + setDrillPath([...drillPathRef.current, node.id]); + }} + /> + )} +
+
+ ); +} + +/** 节点检查器:trigger 编辑调度;reviewer/maintainer 嵌入角色配置;其余展示只读信息 */ +function NodeInspector({ + node, + definition, + project, + generated, + onChange, + onConfigSaved, + onDrillIn, + onMaterializeSubgraph, +}: { + node: PipelineNodeDto; + definition: PipelineDefinitionDto; + project: Project | null; + /** 正本是否仍是自动生成件(画布保存后转人类正本) */ + generated: boolean; + onChange: (node: PipelineNodeDto) => void; + /** 角色配置保存后刷新管线(生成件可能已被重建) */ + onConfigSaved: () => void; + /** 钻取已有子图 */ + onDrillIn: (nodeId: string) => void; + /** 为角色节点生成默认子图并钻取 */ + onMaterializeSubgraph: (node: PipelineNodeDto) => void; +}) { + const roleType = node.type.startsWith('role.') ? node.type.slice(5) : null; + const upstreamTrigger = + roleType != null + ? definition.nodes.find( + candidate => + candidate.type === 'trigger.cron' && + definition.edges.some( + edge => edge.from.node === candidate.id && edge.to.node === node.id + ) + ) + : undefined; + + return ( + + ); +} diff --git a/src/electron/renderer/components/PipelineGraph.tsx b/src/electron/renderer/components/PipelineGraph.tsx new file mode 100644 index 0000000..e216675 --- /dev/null +++ b/src/electron/renderer/components/PipelineGraph.tsx @@ -0,0 +1,196 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { Network } from 'vis-network'; +import { DataSet } from 'vis-data'; +import type { PipelineDefinitionDto, PipelineNodeDto, PipelineRunDto } from '../../shared/types.js'; + +/** 节点类型分组 → 展示色与形状 */ +const TYPE_STYLES: Record = { + trigger: { color: '#f59e0b', shape: 'diamond' }, + role: { color: '#4f46e5', shape: 'box' }, + agent: { color: '#0d9488', shape: 'box' }, + knowledge: { color: '#059669', shape: 'ellipse' }, + sink: { color: '#64748b', shape: 'triangleDown' }, + default: { color: '#8b5cf6', shape: 'box' }, +}; + +/** stage 状态 → 节点边框色(运行状态叠加) */ +const STATUS_BORDER: Record = { + running: '#f59e0b', + succeeded: '#22c55e', + failed: '#ef4444', + skipped: '#94a3b8', +}; + +function typeGroup(type: string): string { + return type.split('.')[0] || 'default'; +} + +/** 最近一轮运行中各节点的状态(nodeId → status) */ +export function latestStageStatus(runs: PipelineRunDto[]): Map { + const map = new Map(); + const latest = runs[0]; + if (!latest) return map; + for (const stage of latest.stages) { + map.set(stage.nodeId, stage.status); + } + return map; +} + +/** 结构签名:仅当节点集合/边连接关系/布局模式变化时才需要重建 Network */ +function structureSignature(definition: PipelineDefinitionDto): string { + return JSON.stringify({ + nodes: definition.nodes.map(node => `${node.id}:${node.type}`).sort(), + edges: definition.edges + .map(edge => `${edge.from.node}.${edge.from.port}->${edge.to.node}.${edge.to.port}`) + .sort(), + // 全部节点有坐标 → 自由布局;坐标值本身的变化不重建(呈现效应原地更新) + freeLayout: definition.nodes.every(node => node.position), + }); +} + +function toVisNode(node: PipelineNodeDto, status: string | undefined) { + const group = TYPE_STYLES[typeGroup(node.type)] ?? TYPE_STYLES.default; + return { + id: node.id, + label: `${node.label ?? node.id}\n${node.type}`, + color: { + background: group.color, + border: status ? (STATUS_BORDER[status] ?? group.color) : group.color, + }, + borderWidth: status ? 4 : 1, + shape: group.shape as never, + font: { color: '#ffffff' }, + ...(node.position ? { x: node.position.x, y: node.position.y } : {}), + }; +} + +interface PipelineGraphProps { + definition: PipelineDefinitionDto; + runs: PipelineRunDto[]; + /** 选中节点时回调(null = 取消选中) */ + onSelectNode: (nodeId: string | null) => void; + /** 拖拽结束:固化全部节点坐标(一次性转入自由布局,避免部分有坐标导致弹回) */ + onMoveAllNodes: (positions: Record) => void; + /** 新增连线(from/to 节点 id;端口映射由父组件负责) */ + onAddEdge: (fromNodeId: string, toNodeId: string) => void; + onDeleteNodes: (nodeIds: string[]) => void; + onDeleteEdges: (edgeIds: string[]) => void; +} + +/** + * 管线画布:vis-network 渲染定义图,叠加最近运行状态。 + * + * 生命周期策略(避免轮询/检查器编辑把画布整个拆掉): + * - 结构效应:仅节点/边连接关系变化时重建 Network + * - 呈现效应:label/位置/运行状态变化时 DataSet 原地更新,缩放平移与选中态保持 + */ +export function PipelineGraph({ + definition, + runs, + onSelectNode, + onMoveAllNodes, + onAddEdge, + onDeleteNodes, + onDeleteEdges, +}: PipelineGraphProps) { + const containerRef = useRef(null); + const networkRef = useRef(null); + const nodesDataRef = useRef | null>(null); + const statusMap = useMemo(() => latestStageStatus(runs), [runs]); + const signature = structureSignature(definition); + + // 结构效应:仅当连接关系变化时重建 + useEffect(() => { + if (!containerRef.current) return undefined; + + const nodes = new DataSet( + definition.nodes.map(node => toVisNode(node, statusMap.get(node.id))) + ); + nodesDataRef.current = nodes; + const edges = new DataSet( + definition.edges.map((edge, index) => ({ + id: edge.id ?? `edge-${index}`, + from: edge.from.node, + to: edge.to.node, + label: edge.channel === 'memory' ? (edge.artifactType ?? '') : edge.channel, + dashes: edge.channel !== 'memory', + arrows: 'to' as const, + })) + ); + + const hasPositions = definition.nodes.every(node => node.position); + const network = new Network( + containerRef.current, + { nodes, edges }, + { + layout: hasPositions + ? { improvedLayout: false } + : { hierarchical: { enabled: true, direction: 'LR', sortMethod: 'directed' } }, + physics: { enabled: false }, + interaction: { multiselect: true }, + manipulation: { + enabled: true, + addNode: false, + editNode: false, + // 不直接改 DataSet:上抛给父组件更新定义,由状态重建图 + addEdge: ( + edgeData: { from?: string | number; to?: string | number }, + callback: (data?: unknown) => void + ) => { + if (edgeData.from != null && edgeData.to != null) { + onAddEdge(String(edgeData.from), String(edgeData.to)); + } + // 故意不调用 callback(edgeData):定义状态重建会覆盖 + void callback; + }, + deleteNode: ( + data: { nodes?: Array }, + callback: (data?: unknown) => void + ) => { + if (data.nodes?.length) onDeleteNodes(data.nodes.map(String)); + void callback; + }, + deleteEdge: ( + data: { edges?: Array }, + callback: (data?: unknown) => void + ) => { + if (data.edges?.length) onDeleteEdges(data.edges.map(String)); + void callback; + }, + }, + } + ); + networkRef.current = network; + + network.on('selectNode', params => { + onSelectNode(params.nodes[0] != null ? String(params.nodes[0]) : null); + }); + network.on('deselectNode', () => onSelectNode(null)); + network.on('dragEnd', params => { + if (!params.nodes?.length) return; + // 一次性固化全部节点坐标:部分节点无坐标会导致层级布局把被拖节点弹回 + const all = network.getPositions(); + const positions: Record = {}; + for (const [nodeId, pos] of Object.entries(all)) { + positions[nodeId] = { x: Math.round(pos.x), y: Math.round(pos.y) }; + } + onMoveAllNodes(positions); + }); + + return () => { + network.destroy(); + networkRef.current = null; + nodesDataRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signature]); + + // 呈现效应:label/坐标/运行状态原地更新,不打断缩放与选中态 + useEffect(() => { + const nodesData = nodesDataRef.current; + if (!nodesData) return; + nodesData.update(definition.nodes.map(node => toVisNode(node, statusMap.get(node.id)))); + }, [definition, statusMap]); + + return
; +} diff --git a/src/electron/renderer/components/ProjectCard.tsx b/src/electron/renderer/components/ProjectCard.tsx index cfe966d..626c666 100755 --- a/src/electron/renderer/components/ProjectCard.tsx +++ b/src/electron/renderer/components/ProjectCard.tsx @@ -12,6 +12,7 @@ export interface ProjectSummary { organized: number; flagged: number; lastScannedAt: number | null; + roleStates?: Partial>; } export function ProjectCard({ project }: { project: ProjectSummary }) { @@ -24,13 +25,29 @@ export function ProjectCard({ project }: { project: ProjectSummary }) {

{project.name}

{project.rootPath}
- {project.archiveRoot && ( -
归档位置: {project.archiveRoot}
- )} + {project.archiveRoot &&
归档位置: {project.archiveRoot}
} +
+ {( + [ + ['reviewer', 'Reviewer'], + ['maintainer', 'Maintainer'], + ['archiver', 'Archiver'], + ] as const + ).map(([role, label]) => ( + + {label}: {project.roleStates?.[role] ? '配置启用' : '未启用'} + + ))} +
健康度
-
{healthPercent}%
+
+ {healthPercent}% +
已复制
@@ -46,7 +63,9 @@ export function ProjectCard({ project }: { project: ProjectSummary }) {
- 待处理: {project.pending} · 已归档: {project.archived} · 已忽略: {project.ignored} · 已孤儿: {project.orphaned} · 最后扫描: {project.lastScannedAt ? new Date(project.lastScannedAt).toLocaleString() : '从未'} + 待处理: {project.pending} · 已归档: {project.archived} · 已忽略: {project.ignored} · 已孤儿:{' '} + {project.orphaned} · 最后扫描:{' '} + {project.lastScannedAt ? new Date(project.lastScannedAt).toLocaleString() : '从未'}
); diff --git a/src/electron/renderer/components/ProjectSettingsPanel.tsx b/src/electron/renderer/components/ProjectSettingsPanel.tsx new file mode 100644 index 0000000..087a6d0 --- /dev/null +++ b/src/electron/renderer/components/ProjectSettingsPanel.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState } from 'react'; +import { invoke, showOpenDialog } from '../api/electron-api.js'; +import { useIpc } from '../hooks/useIpc.js'; +import type { Project } from '../../shared/types.js'; + +export function ProjectSettingsPanel({ projectId }: { projectId: string }) { + const { data: project, refresh } = useIpc('project.get', { projectId }); + const { data: projectConfig, refresh: refreshConfig } = useIpc<{ content: string }>( + 'project.config', + { projectId } + ); + const [gitlab, setGitlab] = useState({ + baseUrl: '', + projectPath: '', + token: '', + defaultBranch: 'main', + }); + const [archiveRoot, setArchiveRoot] = useState(''); + const [configContent, setConfigContent] = useState(''); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (project) { + setArchiveRoot(project.archiveRoot ?? ''); + setGitlab({ + baseUrl: project.gitlab?.baseUrl ?? '', + projectPath: project.gitlab?.projectPath ?? '', + token: project.gitlab?.token ?? '', + defaultBranch: project.gitlab?.defaultBranch ?? 'main', + }); + } + }, [project]); + useEffect(() => setConfigContent(projectConfig?.content ?? ''), [projectConfig]); + + const saveGitlab = async () => { + try { + setError(null); + await invoke('project.gitlab.config.update', { projectId, gitlab }); + setMessage('GitLab 配置已保存'); + refresh(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } + }; + const saveArchiveRoot = async () => { + try { + setError(null); + await invoke('project.archive-root.update', { projectId, archiveRoot }); + setMessage('归档位置已保存'); + refresh(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } + }; + const saveConfig = async () => { + try { + setError(null); + await invoke('project.config.update', { projectId, content: configContent }); + setMessage('扫描规则已保存'); + refreshConfig(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } + }; + const pickArchiveRoot = async () => { + const result = await showOpenDialog({ + title: '选择归档目录', + properties: ['openDirectory', 'createDirectory'], + }); + if (!result.canceled && result.filePaths[0]) setArchiveRoot(result.filePaths[0]); + }; + + if (!project) return
加载项目设置...
; + return ( +
+ {message &&
{message}
} + {error &&
保存失败:{error}
} +
+
+

GitLab 项目

+ 项目级连接配置 +
+ + + + + +
+
+
+

归档位置

+ 留空使用项目内 .codekeeper +
+
+ setArchiveRoot(e.target.value)} /> + +
+ +
+
+
+

扫描规则

+ .codekeeper/config.yaml +
+