From fc26970f8a5334675fe01b104186d5403c53a03f Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Tue, 23 Jun 2026 20:20:09 +0800 Subject: [PATCH 01/21] feat(ci): add MR knowledge extraction pipeline with reaction/reject interaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `teamai ci extract-mr` command for CI pipeline integration: - Extract learnings and codebase suggestions from MR/PR via AI - Post each suggestion as individual comment with unique marker - Reviewer rejects unwanted items via reaction (GitHub 👎 / TGit ☝️) - Post-merge: filter out rejected items, write approved knowledge to team repo - Auto-configure git committer from token API (passes TGit committer-check) - Fix TGit API: use ?iid= query and global MR id for Notes API - Fix AI output: strip preamble before YAML frontmatter - CI templates for GitHub Actions and Coding CI (QCI) - Update README with CI Integration section --story=0 --- .gitignore | 1 + README.md | 37 ++++ RELEASE_NOTE_v0.16.8.md | 286 ++++++++++++++++++++++++++ examples/ci/coding-ci-mr-extract.yaml | 4 +- src/__tests__/read-rejections.test.ts | 63 ++++++ src/ci/extract-mr.ts | 124 +++++++++-- src/ci/mr-comment.ts | 165 +++++++++++++++ src/ci/read-rejections.ts | 200 ++++++++++++++++++ src/index.ts | 1 + 9 files changed, 863 insertions(+), 18 deletions(-) create mode 100644 RELEASE_NOTE_v0.16.8.md create mode 100644 src/__tests__/read-rejections.test.ts create mode 100644 src/ci/read-rejections.ts diff --git a/.gitignore b/.gitignore index 7041ac7..644ed48 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ __pycache__/ .gstack/ .worktrees/ .codebuddy/ +.teamai/ # Local-only drafts and scratch notes (kept out of the open-source repo) eval/ diff --git a/README.md b/README.md index 7edfe7f..eb12863 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ The CLI picks a provider automatically from the repo URL: | `teamai domains drift [url] [--apply \| --lock \| --apply-all]` | Inspect and resolve domain-drift signals; `--apply` reassigns the repo to the recommended domain and refreshes the aggregate views | | `teamai digest` | Generate a team AI usage weekly digest (skill leaderboard, new/updated skills, session summaries) | | `teamai hooks` | Manage AI-tool hooks (list / inject / remove) | +| `teamai ci extract-mr --url [--mode comment\|write\|both] [--individual-comments]` | CI pipeline integration: extract knowledge from MR/PR, post as comments, and write to team repo after merge. With `--individual-comments`, each suggestion is posted separately with reaction/reject support (GitHub 👎 / TGit ☝️) | | `teamai uninstall [--force]` | Uninstall teamai: remove hooks, rules, skills, env, docs, and `~/.teamai/` | | `teamai doctor` | Diagnose configuration problems | @@ -361,6 +362,42 @@ Auto-update runs on the Stop hook at the end of a session. It can be controlled The user-level `updatePolicy` always wins over the team-level `autoUpdate`. +## CI Integration + +TeamAI can integrate into your CI pipeline to automatically extract knowledge from every MR/PR: + +``` +MR opened/updated → CI extracts learning + codebase suggestions → posts as comments + → Reviewer rejects unwanted suggestions (GitHub 👎 / TGit ☝️) + → MR merged → CI writes approved items to team knowledge repo +``` + +### Quick Start + +```bash +# Comment mode: post suggestions to MR (run on PR open/update) +teamai ci extract-mr --url "$MR_URL" --mode comment --individual-comments + +# Write mode: write approved items to knowledge repo (run after merge) +teamai ci extract-mr --url "$MR_URL" --mode write --team-repo ./team-repo --individual-comments +``` + +### CI Templates + +Ready-to-use templates in `examples/ci/`: + +| File | Platform | +|------|----------| +| `github-actions-mr-extract.yml` | GitHub Actions | +| `coding-ci-mr-extract.yaml` | Coding CI (TGit + ZhiYan QCI) | + +### Reject Interaction + +| Platform | How to reject | Default | +|----------|--------------|---------| +| GitHub | Add 👎 reaction to the suggestion comment | Write all | +| TGit | Add ☝️ emoji to the suggestion note | Write all | + ## License [MIT](LICENSE) diff --git a/RELEASE_NOTE_v0.16.8.md b/RELEASE_NOTE_v0.16.8.md new file mode 100644 index 0000000..4566274 --- /dev/null +++ b/RELEASE_NOTE_v0.16.8.md @@ -0,0 +1,286 @@ +# teamai-cli v0.16.8 — 团队 AI 知识飞轮 MVP + +本版本实现了团队 AI 知识飞轮的最小闭环:**知识库初始化 → 智能检索 → 贡献反馈**,团队成员从安装到受益只需 5 分钟。 + +--- + +## 快速开始(5 分钟上手) + +```bash +# 安装(内部) +tnpm install -g @tencent/teamai-cli + +# 安装(公网) +npm install -g teamai-cli + +# 初始化(首次,配置团队仓库) +teamai init --repo <团队仓库地址> + +# 同步团队知识到本地(部署 recall agent + 构建索引 + 注入 hooks) +teamai pull + +# 开始编码 — 一切自动生效,无需额外操作 +# recall agent 自动在每个任务前检索团队知识库 +# hooks 自动追踪使用情况和贡献判断 +``` + +--- + +## 核心能力详解 + +### 知识库初始化(Cold Start / import) + +**本地文档导入** + +```bash +teamai import --dir ./docs # 扫描本地 Markdown 文档 +teamai import --from-claude # 导入 Claude/Cursor 规则目录 +teamai import --workspace # 从当前 git 仓库生成 codebase.md +``` + +**远程仓库导入** + +```bash +teamai import --from-repo # 单仓导入 +teamai import --from-repo-list .teamai/repos.yaml # 批量导入 +``` + +**组织级一键初始化** + +```bash +teamai import --from-org <工蜂 group ID> # 工蜂 group +teamai import --from-org # GitHub org +teamai import --from-org --bootstrap # 含交互式域确认 +``` + +- AI 自动聚类仓库为业务域,生成 `domains.yaml` +- 产出仓库白名单 + 业务域字典 + 全量 codebase 文档 +- 认证三层兜底:token → SSH → public + +**iWiki 导入** + +```bash +teamai import --from-iwiki # 需要 TAI_PAT_TOKEN +``` + +**MR 历史提炼** + +```bash +teamai import --from-mr +``` + +从合入的 MR 提取 learning 草稿(confidence: 0.85)和 codebase 更新建议,自动与近期 session learnings 去重。 + +**增量同步** + +```bash +teamai import --from-repo-list .teamai/repos.yaml --incremental +``` + +SHA 未变化时跳过 AI 扫描;章节级 diff,只覆写变化部分,保留人工批注。 + +--- + +### 检索 Subagent(自动可用) + +`teamai pull` 后自动部署 `teamai-recall` subagent。主对话通过 Agent tool 自动调用,CLAUDE.md 注入触发规则,无需手动操作。 + +**检索范围** + +| 知识类型 | 说明 | +|---------|------| +| Learnings | 团队成员贡献的经验总结 | +| Docs | 团队文档 | +| Rules | 编码规则 | +| Skills | 技能/Slash Commands | +| Codebase | `import --from-repo` 产物 | + +**智能增强** + +- Domain 推断加权:technical > neutral > ops > support +- IDF 权重 + Vote 加分排序 +- 错误自动检索:Bash 报错时 hook 自动触发知识库搜索 + +**手动检索** + +```bash +teamai recall "k8s pod crashloop" +``` + +--- + +### 贡献反馈(contribute-check) + +Session 结束时,Stop hook 自动判断是否值得贡献: + +- 知识库空白感知:recall 均未命中(`hitCount=0`)时触发更强贡献提示 +- 低覆盖感知:命中质量低(`topScore < 5.0`)时温和引导 +- git commit 检测:有 commit 的 session 降低触发权重,避免与 MR learning 重复 + +**贡献流程** + +``` +/teamai-share-learnings # AI 生成经验总结 + → teamai contribute --file # 推送到团队仓库 learnings/ 目录 + → 下次 teamai pull 时所有成员可检索到 +``` + +推送失败时自动本地 commit 保护数据,下次 pull 时重试。 + +--- + +### MR 驱动知识沉淀(P4.4) + +每次 MR 合入后双路输出: + +- learning 草稿(confidence: 0.85,已过 code review) +- codebase.md 变更建议(新服务 / 接口变更 / 架构决策) + +SessionStart hook 自动提醒最近合入但未导入的 MR。 + +```bash +teamai import --from-mr +``` + +--- + +## 团队级 Codebase 知识库 + +### 业务域字典 + +域配置存储于 `.teamai/domains.yaml`,支持一键初始化: + +```bash +teamai import --from-org --bootstrap +``` + +AI 聚类仓库为业务域(如"接入层"、"后端服务"、"基础设施"),CLI 交互确认后写入 `domains.yaml`。 + +### 单仓导入 + +```bash +teamai import --from-repo # 不指定域 +teamai import --from-repo --domain 接入层 # 显式指定域 +``` + +### 批量导入 + +```bash +teamai import --from-repo-list .teamai/repo-whitelist.yaml +teamai import --from-repo-list .teamai/repo-whitelist.yaml --concurrency 5 +``` + +默认并发 3 仓,产物按域拆分为 `docs/codebase/domain-*.md`。 + +### 增量同步 + +```bash +teamai import --from-repo-list .teamai/repo-whitelist.yaml --incremental +``` + +仅重扫变更涉及的模块,并自动检测域漂移。 + +--- + +## 运维与治理 + +### 缓存管理 + +```bash +teamai cache --status # 查看缓存状态 +teamai cache --gc # 清理过期缓存(LRU,默认 5GB 上限) +teamai cache --gc --dry-run # 预览清理结果,不实际删除 +``` + +### Codebase 健康度 + +```bash +teamai codebase --lint # 检查文档一致性 +teamai codebase --lint --fix # 自动修复低风险问题 +teamai codebase --lint --severity high # 只报高级别问题 +teamai codebase --lint --json # 机器可读输出(供 CI 消费) +``` + +### Pending Review + +```bash +teamai review # 列出待审核项 +teamai review --apply # 接受并应用 +teamai review --reject # 拒绝 +teamai review --all-apply --max-risk low # 批量接受低风险项 +``` + +### 域漂移 + +```bash +teamai domains drift # 列出漂移建议 +teamai domains drift --apply # 接受指定仓库的重分类 +teamai domains drift --apply-all --threshold 0.8 # 自动应用高置信项 +``` + +--- + +## 会话 Dashboard & Analytics + +```bash +teamai stats # 本地 skill 使用统计 +teamai dashboard # 启动 Web UI(默认端口 3721),实时查看团队编码会话 +teamai digest # 生成团队周活动摘要 +teamai save-session # 保存当前会话摘要 +``` + +- `teamai track` / `teamai track-slash`:hook 自动调用,追踪工具使用事件 +- `teamai dashboard-report`:hook 自动上报会话状态 +- 数据存储路径:`~/.teamai/usage.jsonl` + +--- + +## 支持的 AI 编码工具 + +| 工具 | Recall Agent | Hooks | CLAUDE.md 规则注入 | +|------|:---:|:---:|:---:| +| Claude Code | ✅ | ✅ | ✅ | +| CodeBuddy | ✅ | ✅ | ✅ | +| Cursor | ✅ | ✅ | — | +| Codex | ✅ | ✅ | — | +| OpenClaw | ✅ | — | — | +| WorkBuddy | ✅ | — | — | + +--- + +## 管理员功能 + +```bash +# 角色管理 +teamai roles init / add / remove / set + +# 标签管理 +teamai tags add / remove / subscribe / unsubscribe + +# 跨团队源 +teamai source add / remove / browse + +# 环境变量 +teamai env add / remove / list + +# 成员管理 +teamai members list +``` + +--- + +## 已知限制与注意事项 + +- `--from-org` 需要认证:工蜂使用 `~/.netrc` 或 `gf auth login`;GitHub 使用 `GITHUB_TOKEN` 或 `gh auth login` +- `--from-iwiki` 需要设置环境变量 `TAI_PAT_TOKEN` +- AI codebase 扫描大仓库可能需要数分钟,超时上限为 12 分钟 +- 搜索索引中 codebase 条目的 Score 可能显示为 NaN,不影响排序,后续版本修复 +- Phase 3(Vote 双计数器)和 Phase 4(置信度维护)将在后续版本实现 +- 工蜂的 `--from-org` 支持已通过 TGit listOrgRepos 实现 + +--- + +## 反馈渠道 + +- 内部群聊 / issue 跟踪 +- `teamai doctor` 可自助诊断配置问题 diff --git a/examples/ci/coding-ci-mr-extract.yaml b/examples/ci/coding-ci-mr-extract.yaml index 252fc94..89c3243 100644 --- a/examples/ci/coding-ci-mr-extract.yaml +++ b/examples/ci/coding-ci-mr-extract.yaml @@ -53,8 +53,8 @@ stages: --write-mode direct - | cd team-repo - git config user.name "teamai-ci" - git config user.email "teamai-ci@tencent.com" + # git user.name/email 由 teamai ci extract-mr 自动从 token 对应账号获取 + # 确保 TAI_PAT_TOKEN 对应的账号对知识仓库有 push 权限即可 git add learnings/ docs/ .teamai/ git diff --cached --quiet || \ git commit -m "[teamai] Extract knowledge from MR !${CI_MERGE_REQUEST_IID}" diff --git a/src/__tests__/read-rejections.test.ts b/src/__tests__/read-rejections.test.ts new file mode 100644 index 0000000..eac01d7 --- /dev/null +++ b/src/__tests__/read-rejections.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { extractMarkerId, shouldWrite } from '../ci/read-rejections.js'; +import type { RejectionResult } from '../ci/read-rejections.js'; + +describe('extractMarkerId', () => { + it('提取 learning marker', () => { + expect(extractMarkerId('\nsome content')).toBe('learning'); + }); + + it('提取 suggestion marker', () => { + expect(extractMarkerId('\ncontent')).toBe('suggestion:1'); + }); + + it('提取 suggestion:2 marker', () => { + expect(extractMarkerId('')).toBe('suggestion:2'); + }); + + it('无 marker 返回 null', () => { + expect(extractMarkerId('普通 comment 内容')).toBeNull(); + }); + + it('其他 teamai marker 不匹配', () => { + expect(extractMarkerId('')).toBeNull(); + }); +}); + +describe('shouldWrite', () => { + const rejections: RejectionResult = { + rejectedIds: new Set(['suggestion:2']), + approvedIds: new Set(['learning', 'suggestion:1']), + allIds: new Set(['learning', 'suggestion:1', 'suggestion:2']), + }; + + describe('GitHub (默认写入,👎 = reject)', () => { + it('不在 rejectedIds 中 → 写入', () => { + expect(shouldWrite('learning', rejections, 'github')).toBe(true); + expect(shouldWrite('suggestion:1', rejections, 'github')).toBe(true); + }); + + it('在 rejectedIds 中 → 不写入', () => { + expect(shouldWrite('suggestion:2', rejections, 'github')).toBe(false); + }); + + it('未知 id(不在任何集合中)→ 写入(默认)', () => { + expect(shouldWrite('suggestion:99', rejections, 'github')).toBe(true); + }); + }); + + describe('TGit (默认写入,🚫 emoji = reject)', () => { + it('不在 rejectedIds 中 → 写入', () => { + expect(shouldWrite('learning', rejections, 'tgit')).toBe(true); + expect(shouldWrite('suggestion:1', rejections, 'tgit')).toBe(true); + }); + + it('在 rejectedIds 中 → 不写入', () => { + expect(shouldWrite('suggestion:2', rejections, 'tgit')).toBe(false); + }); + + it('未知 id(不在任何集合中)→ 写入(默认)', () => { + expect(shouldWrite('suggestion:99', rejections, 'tgit')).toBe(true); + }); + }); +}); diff --git a/src/ci/extract-mr.ts b/src/ci/extract-mr.ts index 424658c..3d63998 100644 --- a/src/ci/extract-mr.ts +++ b/src/ci/extract-mr.ts @@ -18,7 +18,8 @@ import { appendPendingReview } from '../review-store.js'; import { pushRepoDirectly } from '../utils/git.js'; import { log } from '../utils/logger.js'; import type { LearningDraft, CodebaseSuggestion } from '../types.js'; -import { postOrUpdateMrComment } from './mr-comment.js'; +import { postOrUpdateMrComment, postIndividualComments, parseMrUrl } from './mr-comment.js'; +import { readRejections, shouldWrite } from './read-rejections.js'; // ─── 类型 ──────────────────────────────────────────────── @@ -31,6 +32,62 @@ export interface CiExtractMrOptions { writeMode?: 'direct' | 'pending-review'; output?: string; dryRun?: boolean; + individualComments?: boolean; +} + +// ─── Git User 配置 ─────────────────────────────────────── + +/** + * 自动配置 git user.name 和 user.email。 + * + * 通过 provider API 获取当前 token 对应的用户信息: + * - GitHub: GITHUB_TOKEN → GET /user + * - TGit: TAI_PAT_TOKEN → GET /api/v3/user + */ +async function configureGitUser(repoPath: string, provider: 'github' | 'tgit'): Promise { + const { execFileSync } = await import('node:child_process'); + let name = 'teamai-ci'; + let email = 'teamai-ci@noreply'; + + try { + if (provider === 'github') { + const token = process.env['GITHUB_TOKEN']; + if (token) { + const resp = await fetch('https://api.github.com/user', { + headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'teamai-cli' }, + signal: AbortSignal.timeout(8000), + }); + if (resp.ok) { + const user = (await resp.json()) as { login: string; email: string | null }; + name = user.login; + email = user.email ?? `${user.login}@users.noreply.github.com`; + } + } + } else { + const token = process.env['TAI_PAT_TOKEN']; + if (token) { + const resp = await fetch('https://git.woa.com/api/v3/user', { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(8000), + }); + if (resp.ok) { + const user = (await resp.json()) as { username: string; email: string }; + name = user.username; + email = user.email; + } + } + } + } catch { + log.debug('无法获取用户信息,使用默认 git user'); + } + + try { + execFileSync('git', ['config', 'user.name', name], { cwd: repoPath, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', email], { cwd: repoPath, stdio: 'ignore' }); + log.debug(`Git user: ${name} <${email}>`); + } catch { + log.debug('git config 失败(非 git 仓库),跳过'); + } } // ─── 写入逻辑 ──────────────────────────────────────────── @@ -102,6 +159,8 @@ async function writeKnowledgeToRepo( // 提交并推送 if (!dryRun && changedFiles.length > 0) { try { + const provider = mrUrl.includes('github.com') ? 'github' as const : 'tgit' as const; + await configureGitUser(teamRepo, provider); await pushRepoDirectly(teamRepo, `[teamai] CI extract knowledge from MR`, changedFiles); log.success('已推送到团队仓库'); } catch (err) { @@ -185,29 +244,62 @@ export async function ciExtractMr(opts: CiExtractMrOptions): Promise { // 执行 comment if (opts.mode === 'comment' || opts.mode === 'both') { - const result = await postOrUpdateMrComment( - opts.url, - learning, - suggestions, - opts.commentMarker, - opts.dryRun, - ); - if (result.created) { - log.success('MR comment 已发布'); + if (opts.individualComments) { + const { posted } = await postIndividualComments(opts.url, learning, suggestions, opts.dryRun); + log.success(`已发布 ${posted} 条独立建议 comment`); } else { - log.success('MR comment 已更新'); - } - if (result.url) { - log.info(`Comment URL: ${result.url}`); + const result = await postOrUpdateMrComment( + opts.url, + learning, + suggestions, + opts.commentMarker, + opts.dryRun, + ); + if (result.created) { + log.success('MR comment 已发布'); + } else { + log.success('MR comment 已更新'); + } + if (result.url) { + log.info(`Comment URL: ${result.url}`); + } } } // 执行 write if (opts.mode === 'write' || opts.mode === 'both') { + // 当使用 individual comments 时,读取 rejection 状态进行过滤 + let filteredLearning = learning; + let filteredSuggestions = suggestions; + + if (opts.individualComments && !opts.dryRun) { + const parsed = parseMrUrl(opts.url); + const rejections = await readRejections(opts.url); + + if (rejections.allIds.size > 0) { + // 过滤 learning + if (learning && !shouldWrite('learning', rejections, parsed.provider)) { + log.info('Learning 被 reject,跳过写入'); + filteredLearning = undefined; + } + + // 过滤 suggestions + if (suggestions) { + filteredSuggestions = suggestions.filter((_, i) => + shouldWrite(`suggestion:${i + 1}`, rejections, parsed.provider), + ); + const rejected = suggestions.length - filteredSuggestions.length; + if (rejected > 0) { + log.info(`${rejected} 条 codebase 建议被 reject,已排除`); + } + } + } + } + await writeKnowledgeToRepo( opts.teamRepo!, - learning, - suggestions, + filteredLearning, + filteredSuggestions, opts.writeMode ?? 'direct', opts.url, opts.dryRun, diff --git a/src/ci/mr-comment.ts b/src/ci/mr-comment.ts index 066c86c..d1affc5 100644 --- a/src/ci/mr-comment.ts +++ b/src/ci/mr-comment.ts @@ -340,3 +340,168 @@ export async function postOrUpdateMrComment( } return postTGitComment(projectId, mrGlobalId, body); } + +// ─── 逐条发布(Individual Comments)───────────────────── + +/** + * 格式化单条建议为独立 comment 内容。 + */ +export function formatIndividualComment( + type: 'learning' | 'suggestion', + index: number, + content: { title?: string; body?: string; section?: string; action?: string; preview?: string }, + provider: 'github' | 'tgit', +): string { + const markerId = type === 'learning' ? 'learning' : `suggestion:${index}`; + const marker = ``; + + const lines: string[] = [marker, '']; + + if (type === 'learning') { + lines.push(`## TeamAI Learning`, ''); + lines.push(`**${content.title}**`, ''); + if (content.body) { + lines.push('
', '展开完整内容', ''); + lines.push('```markdown'); + lines.push(content.body); + lines.push('```'); + lines.push('', '
'); + } + } else { + lines.push(`## TeamAI Codebase 建议 #${index}`, ''); + lines.push(`**章节**: ${content.section} | **操作**: ${content.action}`, ''); + if (content.preview) { + lines.push('```'); + lines.push(content.preview.slice(0, 300)); + lines.push('```'); + } + } + + lines.push(''); + if (provider === 'github') { + lines.push('> 💡 不写入此条?请对本条 comment 加 👎 reaction'); + } else { + lines.push('> 💡 不写入此条?请对本条评论加 ☝️ emoji reaction'); + } + lines.push(''); + lines.push('_Auto-generated by `teamai ci extract-mr`_'); + + return lines.join('\n'); +} + +/** + * 逐条发布 learning 和 suggestions 为独立的 comment/note。 + * + * 每条建议带唯一 marker,支持后续 readRejections 读取状态。 + * TGit 上发布为 conversation note,reviewer 通过 ☝️ emoji reaction 来 reject。 + */ +export async function postIndividualComments( + mrUrl: string, + learning: LearningDraft | undefined, + suggestions: CodebaseSuggestion[] | undefined, + dryRun?: boolean, +): Promise<{ posted: number }> { + const parsed = parseMrUrl(mrUrl); + let posted = 0; + + const items: Array<{ markerId: string; body: string }> = []; + + // Learning + if (learning) { + const body = formatIndividualComment( + 'learning', 0, + { title: learning.title, body: learning.content }, + parsed.provider, + ); + items.push({ markerId: 'learning', body }); + } + + // Suggestions + if (suggestions) { + for (let i = 0; i < suggestions.length; i++) { + const s = suggestions[i]; + const body = formatIndividualComment( + 'suggestion', i + 1, + { section: s.section, action: s.action, preview: s.content }, + parsed.provider, + ); + items.push({ markerId: `suggestion:${i + 1}`, body }); + } + } + + if (items.length === 0) return { posted: 0 }; + + if (dryRun) { + for (const item of items) { + log.info(`dry-run [${item.markerId}]:`); + console.log(item.body); + console.log('---'); + } + return { posted: items.length }; + } + + if (parsed.provider === 'github') { + for (const item of items) { + const marker = ``; + const existing = await findGitHubComment(parsed.owner, parsed.repo, parsed.number, marker); + if (existing) { + await updateGitHubComment(parsed.owner, parsed.repo, existing.id, item.body); + } else { + await postGitHubComment(parsed.owner, parsed.repo, parsed.number, item.body); + } + posted++; + } + } else { + // TGit: 作为 code review comment(关联文件),带 resolve 按钮 + const projectId = encodeURIComponent(`${parsed.owner}/${parsed.repo}`); + const mrGlobalId = await getMrGlobalId(projectId, parsed.number); + + // 获取 MR 变更文件列表,取第一个文件作为 code review comment 锚点 + // 使用 /repository/compare API(/changes 对某些 MR 状态不可用) + const mrListResp = await tgitRequest( + `/projects/${projectId}/merge_requests?iid=${parsed.number}`, + 'GET', + ); + let anchorFile = 'README.md'; + if (mrListResp.ok) { + const mrList = (await mrListResp.json()) as Array<{ source_branch: string; target_branch: string }>; + if (mrList.length > 0) { + const { source_branch, target_branch } = mrList[0]; + const compareResp = await tgitRequest( + `/projects/${projectId}/repository/compare?from=${target_branch}&to=${source_branch}`, + 'GET', + ); + if (compareResp.ok) { + const compareData = (await compareResp.json()) as { diffs?: Array<{ new_path: string }> }; + if (compareData.diffs && compareData.diffs.length > 0) { + anchorFile = compareData.diffs[0].new_path; + } + } + } + } + + for (const item of items) { + const marker = ``; + // 检查是否已存在 + const existingNote = await findTGitComment(projectId, mrGlobalId, marker); + if (existingNote) { + await updateTGitComment(projectId, mrGlobalId, existingNote.id, item.body); + } else { + // 创建 code review comment(关联到文件第 1 行) + const resp = await tgitRequest( + `/projects/${projectId}/merge_requests/${mrGlobalId}/notes`, + 'POST', + { body: item.body, path: anchorFile, line: 1, line_type: 'new' }, + ); + if (!resp.ok) { + // fallback: 创建普通 note + await postTGitComment(projectId, mrGlobalId, item.body); + } + } + posted++; + } + } + + log.success(`已发布 ${posted} 条独立建议`); + return { posted }; +} diff --git a/src/ci/read-rejections.ts b/src/ci/read-rejections.ts new file mode 100644 index 0000000..41718ca --- /dev/null +++ b/src/ci/read-rejections.ts @@ -0,0 +1,200 @@ +// -*- coding: utf-8 -*- +/** + * 读取 MR/PR 上 reviewer 对知识建议的交互状态。 + * + * - GitHub: 👎 reaction = reject,无 reaction = approve(默认写入) + * - TGit: resolve_state=1 = approve,resolve_state=0 = reject(默认不写入) + */ + +import { parseMrUrl } from './mr-comment.js'; +import { gfGetOAuthToken } from '../providers/tgit/gf-cli.js'; +import { log } from '../utils/logger.js'; + +const API_TIMEOUT_MS = 15_000; + +// ─── 类型 ──────────────────────────────────────────────── + +export interface RejectionResult { + /** 被 reject 的 marker id 集合 (如 "learning", "suggestion:1") */ + rejectedIds: Set; + /** 被 approve 的 marker id 集合 */ + approvedIds: Set; + /** 所有找到的 marker id */ + allIds: Set; +} + +// ─── Marker 解析 ───────────────────────────────────────── + +const MARKER_REGEX = //; + +/** + * 从 comment body 中提取 marker id。 + */ +export function extractMarkerId(body: string): string | null { + const match = body.match(MARKER_REGEX); + return match ? match[1] : null; +} + +// ─── GitHub ───────────────────────────────────────────── + +interface GitHubComment { + id: number; + body: string; +} + +interface GitHubReaction { + content: string; // "+1", "-1", "laugh", etc. +} + +async function githubRequest(path: string): Promise { + const token = process.env['GITHUB_TOKEN']; + if (!token) throw new Error('未设置 GITHUB_TOKEN'); + return fetch(`https://api.github.com${path}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'teamai-cli', + }, + signal: AbortSignal.timeout(API_TIMEOUT_MS), + }); +} + +async function readGitHubRejections(owner: string, repo: string, prNumber: string): Promise { + const result: RejectionResult = { rejectedIds: new Set(), approvedIds: new Set(), allIds: new Set() }; + + // 读取所有 comments + const resp = await githubRequest(`/repos/${owner}/${repo}/issues/${prNumber}/comments?per_page=100`); + if (!resp.ok) return result; + const comments = (await resp.json()) as GitHubComment[]; + + for (const comment of comments) { + const markerId = extractMarkerId(comment.body); + if (!markerId) continue; + result.allIds.add(markerId); + + // 读取 reactions + const reactResp = await githubRequest( + `/repos/${owner}/${repo}/issues/comments/${comment.id}/reactions?per_page=100`, + ); + if (!reactResp.ok) { + result.approvedIds.add(markerId); // 读取失败默认 approve + continue; + } + const reactions = (await reactResp.json()) as GitHubReaction[]; + const hasThumbsDown = reactions.some((r) => r.content === '-1'); + + if (hasThumbsDown) { + result.rejectedIds.add(markerId); + } else { + result.approvedIds.add(markerId); + } + } + + return result; +} + +// ─── TGit ─────────────────────────────────────────────── + +/** TGit emoji 编号 8 = ☝️(竖起食指),作为 reject 信号 */ +const TGIT_REJECT_EMOJI = 8; + +interface TGitNoteComment { + comment: number; + author: { username: string }; +} + +interface TGitNote { + id: number; + body: string; + resolve_state: number; + file_path: string | null; + comments: TGitNoteComment[]; +} + +function getTGitToken(): string { + const envToken = process.env['TAI_PAT_TOKEN']; + if (envToken) return envToken; + const oauthToken = gfGetOAuthToken(); + if (oauthToken) return oauthToken; + throw new Error('未设置 TAI_PAT_TOKEN 且无法获取 OAuth token'); +} + +async function tgitRequest(path: string): Promise { + const token = getTGitToken(); + return fetch(`https://git.woa.com/api/v3${path}`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + signal: AbortSignal.timeout(API_TIMEOUT_MS), + }); +} + +async function getMrGlobalId(projectId: string, mrIid: string): Promise { + const resp = await tgitRequest(`/projects/${projectId}/merge_requests?iid=${mrIid}`); + if (!resp.ok) throw new Error(`TGit 查询 MR 失败 (${resp.status})`); + const mrs = (await resp.json()) as Array<{ id: number; iid: number }>; + const mr = mrs.find((m) => String(m.iid) === mrIid); + if (!mr) throw new Error(`TGit MR !${mrIid} 不存在`); + return mr.id; +} + +async function readTGitRejections(owner: string, repo: string, mrIid: string): Promise { + const result: RejectionResult = { rejectedIds: new Set(), approvedIds: new Set(), allIds: new Set() }; + + const projectId = encodeURIComponent(`${owner}/${repo}`); + const mrGlobalId = await getMrGlobalId(projectId, mrIid); + + // 读取所有 notes + const resp = await tgitRequest(`/projects/${projectId}/merge_requests/${mrGlobalId}/notes?per_page=100`); + if (!resp.ok) return result; + const notes = (await resp.json()) as TGitNote[]; + + for (const note of notes) { + const markerId = extractMarkerId(note.body); + if (!markerId) continue; + result.allIds.add(markerId); + + // TGit: emoji 编号 8 (☝️) = reject,无 emoji = approve(默认写入) + const hasRejectEmoji = (note.comments ?? []).some((c) => c.comment === TGIT_REJECT_EMOJI); + if (hasRejectEmoji) { + result.rejectedIds.add(markerId); + } else { + result.approvedIds.add(markerId); + } + } + + return result; +} + +// ─── Public API ───────────────────────────────────────── + +/** + * 读取 MR/PR 上 reviewer 对知识建议的交互状态。 + * + * 返回被 reject 和 approve 的 marker id 集合。 + * - GitHub: 默认写入,👎 = reject + * - TGit: 默认不写入,点"解决" = approve + */ +export async function readRejections(mrUrl: string): Promise { + const parsed = parseMrUrl(mrUrl); + + if (parsed.provider === 'github') { + log.debug('读取 GitHub reactions...'); + return readGitHubRejections(parsed.owner, parsed.repo, parsed.number); + } + + log.debug('读取 TGit emoji reactions...'); + return readTGitRejections(parsed.owner, parsed.repo, parsed.number); +} + +/** + * 根据 rejection 结果判断某条建议是否应该写入。 + * + * 两个平台逻辑统一:默认写入,被 reject 的不写入。 + * - GitHub: 👎 reaction = reject + * - TGit: ☝️ emoji (编号 8) = reject + */ +export function shouldWrite(markerId: string, rejections: RejectionResult, _provider: 'github' | 'tgit'): boolean { + return !rejections.rejectedIds.has(markerId); +} diff --git a/src/index.ts b/src/index.ts index 1da9849..2823e71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -710,6 +710,7 @@ ciCmd .option('--comment-marker ', 'HTML comment anchor for idempotent updates', '') .option('--write-mode ', 'Write strategy: direct | pending-review', 'direct') .option('--output ', 'Write artifacts to directory') + .option('--individual-comments', 'Post each suggestion as separate comment with reaction/resolve support') .action(async (cmdOpts) => { const globalOpts = program.opts() as GlobalOptions; const { ciExtractMr } = await import('./ci/extract-mr.js'); From b073d5539e24bb652cea0a0880e477baed912a36 Mon Sep 17 00:00:00 2001 From: Jiahe Geng <146067293+m0Nst3r873@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:32:06 +0800 Subject: [PATCH 02/21] Delete RELEASE_NOTE_v0.16.8.md --- RELEASE_NOTE_v0.16.8.md | 286 ---------------------------------------- 1 file changed, 286 deletions(-) delete mode 100644 RELEASE_NOTE_v0.16.8.md diff --git a/RELEASE_NOTE_v0.16.8.md b/RELEASE_NOTE_v0.16.8.md deleted file mode 100644 index 4566274..0000000 --- a/RELEASE_NOTE_v0.16.8.md +++ /dev/null @@ -1,286 +0,0 @@ -# teamai-cli v0.16.8 — 团队 AI 知识飞轮 MVP - -本版本实现了团队 AI 知识飞轮的最小闭环:**知识库初始化 → 智能检索 → 贡献反馈**,团队成员从安装到受益只需 5 分钟。 - ---- - -## 快速开始(5 分钟上手) - -```bash -# 安装(内部) -tnpm install -g @tencent/teamai-cli - -# 安装(公网) -npm install -g teamai-cli - -# 初始化(首次,配置团队仓库) -teamai init --repo <团队仓库地址> - -# 同步团队知识到本地(部署 recall agent + 构建索引 + 注入 hooks) -teamai pull - -# 开始编码 — 一切自动生效,无需额外操作 -# recall agent 自动在每个任务前检索团队知识库 -# hooks 自动追踪使用情况和贡献判断 -``` - ---- - -## 核心能力详解 - -### 知识库初始化(Cold Start / import) - -**本地文档导入** - -```bash -teamai import --dir ./docs # 扫描本地 Markdown 文档 -teamai import --from-claude # 导入 Claude/Cursor 规则目录 -teamai import --workspace # 从当前 git 仓库生成 codebase.md -``` - -**远程仓库导入** - -```bash -teamai import --from-repo # 单仓导入 -teamai import --from-repo-list .teamai/repos.yaml # 批量导入 -``` - -**组织级一键初始化** - -```bash -teamai import --from-org <工蜂 group ID> # 工蜂 group -teamai import --from-org # GitHub org -teamai import --from-org --bootstrap # 含交互式域确认 -``` - -- AI 自动聚类仓库为业务域,生成 `domains.yaml` -- 产出仓库白名单 + 业务域字典 + 全量 codebase 文档 -- 认证三层兜底:token → SSH → public - -**iWiki 导入** - -```bash -teamai import --from-iwiki # 需要 TAI_PAT_TOKEN -``` - -**MR 历史提炼** - -```bash -teamai import --from-mr -``` - -从合入的 MR 提取 learning 草稿(confidence: 0.85)和 codebase 更新建议,自动与近期 session learnings 去重。 - -**增量同步** - -```bash -teamai import --from-repo-list .teamai/repos.yaml --incremental -``` - -SHA 未变化时跳过 AI 扫描;章节级 diff,只覆写变化部分,保留人工批注。 - ---- - -### 检索 Subagent(自动可用) - -`teamai pull` 后自动部署 `teamai-recall` subagent。主对话通过 Agent tool 自动调用,CLAUDE.md 注入触发规则,无需手动操作。 - -**检索范围** - -| 知识类型 | 说明 | -|---------|------| -| Learnings | 团队成员贡献的经验总结 | -| Docs | 团队文档 | -| Rules | 编码规则 | -| Skills | 技能/Slash Commands | -| Codebase | `import --from-repo` 产物 | - -**智能增强** - -- Domain 推断加权:technical > neutral > ops > support -- IDF 权重 + Vote 加分排序 -- 错误自动检索:Bash 报错时 hook 自动触发知识库搜索 - -**手动检索** - -```bash -teamai recall "k8s pod crashloop" -``` - ---- - -### 贡献反馈(contribute-check) - -Session 结束时,Stop hook 自动判断是否值得贡献: - -- 知识库空白感知:recall 均未命中(`hitCount=0`)时触发更强贡献提示 -- 低覆盖感知:命中质量低(`topScore < 5.0`)时温和引导 -- git commit 检测:有 commit 的 session 降低触发权重,避免与 MR learning 重复 - -**贡献流程** - -``` -/teamai-share-learnings # AI 生成经验总结 - → teamai contribute --file # 推送到团队仓库 learnings/ 目录 - → 下次 teamai pull 时所有成员可检索到 -``` - -推送失败时自动本地 commit 保护数据,下次 pull 时重试。 - ---- - -### MR 驱动知识沉淀(P4.4) - -每次 MR 合入后双路输出: - -- learning 草稿(confidence: 0.85,已过 code review) -- codebase.md 变更建议(新服务 / 接口变更 / 架构决策) - -SessionStart hook 自动提醒最近合入但未导入的 MR。 - -```bash -teamai import --from-mr -``` - ---- - -## 团队级 Codebase 知识库 - -### 业务域字典 - -域配置存储于 `.teamai/domains.yaml`,支持一键初始化: - -```bash -teamai import --from-org --bootstrap -``` - -AI 聚类仓库为业务域(如"接入层"、"后端服务"、"基础设施"),CLI 交互确认后写入 `domains.yaml`。 - -### 单仓导入 - -```bash -teamai import --from-repo # 不指定域 -teamai import --from-repo --domain 接入层 # 显式指定域 -``` - -### 批量导入 - -```bash -teamai import --from-repo-list .teamai/repo-whitelist.yaml -teamai import --from-repo-list .teamai/repo-whitelist.yaml --concurrency 5 -``` - -默认并发 3 仓,产物按域拆分为 `docs/codebase/domain-*.md`。 - -### 增量同步 - -```bash -teamai import --from-repo-list .teamai/repo-whitelist.yaml --incremental -``` - -仅重扫变更涉及的模块,并自动检测域漂移。 - ---- - -## 运维与治理 - -### 缓存管理 - -```bash -teamai cache --status # 查看缓存状态 -teamai cache --gc # 清理过期缓存(LRU,默认 5GB 上限) -teamai cache --gc --dry-run # 预览清理结果,不实际删除 -``` - -### Codebase 健康度 - -```bash -teamai codebase --lint # 检查文档一致性 -teamai codebase --lint --fix # 自动修复低风险问题 -teamai codebase --lint --severity high # 只报高级别问题 -teamai codebase --lint --json # 机器可读输出(供 CI 消费) -``` - -### Pending Review - -```bash -teamai review # 列出待审核项 -teamai review --apply # 接受并应用 -teamai review --reject # 拒绝 -teamai review --all-apply --max-risk low # 批量接受低风险项 -``` - -### 域漂移 - -```bash -teamai domains drift # 列出漂移建议 -teamai domains drift --apply # 接受指定仓库的重分类 -teamai domains drift --apply-all --threshold 0.8 # 自动应用高置信项 -``` - ---- - -## 会话 Dashboard & Analytics - -```bash -teamai stats # 本地 skill 使用统计 -teamai dashboard # 启动 Web UI(默认端口 3721),实时查看团队编码会话 -teamai digest # 生成团队周活动摘要 -teamai save-session # 保存当前会话摘要 -``` - -- `teamai track` / `teamai track-slash`:hook 自动调用,追踪工具使用事件 -- `teamai dashboard-report`:hook 自动上报会话状态 -- 数据存储路径:`~/.teamai/usage.jsonl` - ---- - -## 支持的 AI 编码工具 - -| 工具 | Recall Agent | Hooks | CLAUDE.md 规则注入 | -|------|:---:|:---:|:---:| -| Claude Code | ✅ | ✅ | ✅ | -| CodeBuddy | ✅ | ✅ | ✅ | -| Cursor | ✅ | ✅ | — | -| Codex | ✅ | ✅ | — | -| OpenClaw | ✅ | — | — | -| WorkBuddy | ✅ | — | — | - ---- - -## 管理员功能 - -```bash -# 角色管理 -teamai roles init / add / remove / set - -# 标签管理 -teamai tags add / remove / subscribe / unsubscribe - -# 跨团队源 -teamai source add / remove / browse - -# 环境变量 -teamai env add / remove / list - -# 成员管理 -teamai members list -``` - ---- - -## 已知限制与注意事项 - -- `--from-org` 需要认证:工蜂使用 `~/.netrc` 或 `gf auth login`;GitHub 使用 `GITHUB_TOKEN` 或 `gh auth login` -- `--from-iwiki` 需要设置环境变量 `TAI_PAT_TOKEN` -- AI codebase 扫描大仓库可能需要数分钟,超时上限为 12 分钟 -- 搜索索引中 codebase 条目的 Score 可能显示为 NaN,不影响排序,后续版本修复 -- Phase 3(Vote 双计数器)和 Phase 4(置信度维护)将在后续版本实现 -- 工蜂的 `--from-org` 支持已通过 TGit listOrgRepos 实现 - ---- - -## 反馈渠道 - -- 内部群聊 / issue 跟踪 -- `teamai doctor` 可自助诊断配置问题 From c26bf4fd41e66260b5553613414d28aa8c2e222f Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Wed, 24 Jun 2026 11:06:51 +0800 Subject: [PATCH 03/21] docs: update Chinese README with CI integration section Add `teamai ci extract-mr` command to the command table and add the CI Integration section (quick start, templates, reject interaction) to match the English README updated in PR #39. --- README.zh-CN.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/README.zh-CN.md b/README.zh-CN.md index a3a1fb2..c4ae919 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -89,6 +89,7 @@ CLI 会根据用户传入的 repo URL 自动选择 provider: | `teamai domains drift [url] [--apply \| --lock \| --apply-all]` | 浏览并处理域漂移信号;`--apply` 把仓库重新归类到推荐域并刷新聚合视图 | | `teamai digest` | 生成团队 AI 使用周报(skill 排行、新增/更新 skill、session 摘要) | | `teamai hooks` | 管理 AI 工具 hooks(list / inject / remove) | +| `teamai ci extract-mr --url [--mode comment\|write\|both] [--individual-comments]` | CI 流水线集成:从 MR/PR 中提取知识,发布为评论,合并后写入团队知识仓库。使用 `--individual-comments` 时每条建议单独发布,支持 reaction/reject 交互(GitHub 👎 / TGit ☝️) | | `teamai uninstall [--force]` | 卸载 teamai:移除 hooks、rules、skills、env、docs、~/.teamai/ | | `teamai doctor` | 诊断配置问题 | @@ -361,6 +362,42 @@ npm update -g teamai-cli # 或手动触发 npm 升级 用户级 `updatePolicy` 始终优先于团队级 `autoUpdate`。 +## CI 集成 + +TeamAI 可以集成到 CI 流水线中,从每次 MR/PR 自动提取知识: + +``` +MR 创建/更新 → CI 提取 learning + codebase 建议 → 以评论形式发布 + → Reviewer 拒绝不需要的建议(GitHub 👎 / TGit ☝️) + → MR 合并 → CI 将已通过的条目写入团队知识仓库 +``` + +### 快速开始 + +```bash +# Comment 模式:将建议发布到 MR(在 PR 打开/更新时运行) +teamai ci extract-mr --url "$MR_URL" --mode comment --individual-comments + +# Write 模式:将已通过的条目写入知识仓库(在合并后运行) +teamai ci extract-mr --url "$MR_URL" --mode write --team-repo ./team-repo --individual-comments +``` + +### CI 模板 + +`examples/ci/` 目录下提供了开箱即用的模板: + +| 文件 | 平台 | +|------|------| +| `github-actions-mr-extract.yml` | GitHub Actions | +| `coding-ci-mr-extract.yaml` | Coding CI(TGit + 智研 QCI) | + +### 拒绝交互 + +| 平台 | 拒绝方式 | 默认行为 | +|------|---------|---------| +| GitHub | 对建议评论添加 👎 reaction | 全部写入 | +| TGit | 对建议 note 添加 ☝️ emoji | 全部写入 | + ## 许可证 [MIT](LICENSE) From 75a31fba74d1bc5a20337b12f35c021141960c08 Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Wed, 24 Jun 2026 19:57:29 +0800 Subject: [PATCH 04/21] feat(codebase): integrate team-wiki knowledge graph engine Vendor team-wiki's code-knowledge + core packages as the codebase knowledge engine, replacing AI-generated docs/team-codebase/ with structured teamwiki/ knowledge graph. New capabilities: - `teamai codebase --extract`: deterministic code fact extraction (5 languages) + graph-index.json construction - `teamai recall`: BM25 + graph-boost codebase retrieval - `teamai ci extract-mr`: incremental graph update on MR merge with PR comment (learning + graph change summary) - `teamai import --from-repo/org/iwiki`: auto-push + MAPS_TO reconcile - `teamai codebase --lint`: graph health + navigation file checks - Cross-repo edge detection via PascalCase label matching - Knowledge gaps detection (IMPL_MISSING, LOW_CONNECTIVITY, etc.) - Pull protection: skip overwrite when local teamwiki/ is newer Knowledge graph architecture based on Team Wiki by @lurkacai. --other=code-knowledge-graph engine integration (no TAPD story) --- .gitignore | 2 + src/__tests__/ci-extract-mr.test.ts | 13 +- src/ci/extract-mr.ts | 118 ++++- src/ci/mr-comment.ts | 70 +++ src/code-knowledge-recall.ts | 273 ++++++++++++ src/codebase-cmd.ts | 47 +- src/codebase-extract.ts | 382 ++++++++++++++++ src/codebase-upgrade-wiki.ts | 116 +++++ src/codebase-wiki-lint.ts | 250 +++++++++++ src/import-iwiki.ts | 175 ++++++++ src/import-repo.ts | 170 +++++++ src/import.ts | 17 +- src/index.ts | 11 +- src/pull.ts | 25 +- src/recall.ts | 34 +- src/utils/iwiki-client.ts | 2 +- src/wiki-engine/adapters/index.ts | 26 ++ .../code-knowledge/code-collector.ts | 213 +++++++++ .../code-knowledge/code-extractors.ts | 73 +++ src/wiki-engine/code-knowledge/code-graph.ts | 171 +++++++ .../code-knowledge/code-incremental.ts | 45 ++ .../code-knowledge/extractors/go.ts | 130 ++++++ .../code-knowledge/extractors/index.ts | 46 ++ .../code-knowledge/extractors/java.ts | 126 ++++++ .../code-knowledge/extractors/python.ts | 126 ++++++ .../code-knowledge/extractors/rust.ts | 143 ++++++ .../code-knowledge/extractors/typescript.ts | 102 +++++ .../code-knowledge/manifest-schema.ts | 90 ++++ src/wiki-engine/core/graph-index.schema.ts | 417 ++++++++++++++++++ src/wiki-engine/core/wiki-protocol.ts | 197 +++++++++ 30 files changed, 3577 insertions(+), 33 deletions(-) create mode 100644 src/code-knowledge-recall.ts create mode 100644 src/codebase-extract.ts create mode 100644 src/codebase-upgrade-wiki.ts create mode 100644 src/codebase-wiki-lint.ts create mode 100644 src/wiki-engine/adapters/index.ts create mode 100644 src/wiki-engine/code-knowledge/code-collector.ts create mode 100644 src/wiki-engine/code-knowledge/code-extractors.ts create mode 100644 src/wiki-engine/code-knowledge/code-graph.ts create mode 100644 src/wiki-engine/code-knowledge/code-incremental.ts create mode 100644 src/wiki-engine/code-knowledge/extractors/go.ts create mode 100644 src/wiki-engine/code-knowledge/extractors/index.ts create mode 100644 src/wiki-engine/code-knowledge/extractors/java.ts create mode 100644 src/wiki-engine/code-knowledge/extractors/python.ts create mode 100644 src/wiki-engine/code-knowledge/extractors/rust.ts create mode 100644 src/wiki-engine/code-knowledge/extractors/typescript.ts create mode 100644 src/wiki-engine/code-knowledge/manifest-schema.ts create mode 100644 src/wiki-engine/core/graph-index.schema.ts create mode 100644 src/wiki-engine/core/wiki-protocol.ts diff --git a/.gitignore b/.gitignore index 644ed48..5023ea2 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ docs/codebase.md docs/llm-wiki.md roadmap_jael.md validation/ +teamwiki/ +docs/designs/code-knowledge-graph.md diff --git a/src/__tests__/ci-extract-mr.test.ts b/src/__tests__/ci-extract-mr.test.ts index 1bb80a3..e1fd174 100644 --- a/src/__tests__/ci-extract-mr.test.ts +++ b/src/__tests__/ci-extract-mr.test.ts @@ -73,10 +73,11 @@ describe('ciExtractMr', () => { all: true, dryRun: true, })); + // codebase suggestions 不再通过 comment 发布(由图谱变更 comment 替代) expect(mockPostOrUpdateMrComment).toHaveBeenCalledWith( 'https://github.com/org/repo/pull/1', expect.objectContaining({ title: 'Test Learning' }), - expect.arrayContaining([expect.objectContaining({ section: 'arch' })]), + undefined, undefined, undefined, ); @@ -106,14 +107,14 @@ describe('ciExtractMr', () => { expect(learnings.length).toBe(1); expect(learnings[0]).toContain('Test-Learning'); - // codebase 被更新 - expect(mockApplyCodebaseSuggestions).toHaveBeenCalled(); + // codebase direct 模式已被图谱引擎替代,不再调用 applyCodebaseSuggestions + // mockApplyCodebaseSuggestions 不应被调用 - // push 被调用 + // push 被调用(仅含 learning,不含 docs/codebase.md) expect(mockPushRepoDirectly).toHaveBeenCalledWith( teamRepo, expect.stringContaining('[teamai]'), - expect.arrayContaining(['docs/codebase.md']), + expect.not.arrayContaining(['docs/codebase.md']), ); }); @@ -175,7 +176,7 @@ describe('ciExtractMr', () => { expect(mockPostOrUpdateMrComment).toHaveBeenCalledWith( expect.any(String), expect.anything(), - expect.anything(), + undefined, undefined, true, ); diff --git a/src/ci/extract-mr.ts b/src/ci/extract-mr.ts index 3d63998..a133bd8 100644 --- a/src/ci/extract-mr.ts +++ b/src/ci/extract-mr.ts @@ -13,12 +13,12 @@ import path from 'node:path'; import os from 'node:os'; import { importFromMR } from '../import-mr.js'; -import { applyCodebaseSuggestions } from '../codebase.js'; +// applyCodebaseSuggestions removed: codebase updates now handled by teamwiki/ graph engine import { appendPendingReview } from '../review-store.js'; import { pushRepoDirectly } from '../utils/git.js'; import { log } from '../utils/logger.js'; import type { LearningDraft, CodebaseSuggestion } from '../types.js'; -import { postOrUpdateMrComment, postIndividualComments, parseMrUrl } from './mr-comment.js'; +import { postOrUpdateMrComment, postIndividualComments, postCodebaseGraphComment, parseMrUrl } from './mr-comment.js'; import { readRejections, shouldWrite } from './read-rejections.js'; // ─── 类型 ──────────────────────────────────────────────── @@ -102,9 +102,14 @@ async function writeKnowledgeToRepo( writeMode: 'direct' | 'pending-review', mrUrl: string, dryRun?: boolean, + graphWritten?: boolean, ): Promise { const changedFiles: string[] = []; + if (graphWritten) { + changedFiles.push('teamwiki'); + } + // 写入 learning if (learning) { const safeTitle = learning.title @@ -125,20 +130,11 @@ async function writeKnowledgeToRepo( } // 处理 codebase suggestions + // NOTE: direct 模式的 AI 重写已被 teamwiki/ 图谱增量更新替代(Phase 3.3) + // suggestions 仅在 pending-review 模式下写入 jsonl 供人工审阅 if (suggestions && suggestions.length > 0) { if (writeMode === 'direct') { - const codebasePath = path.join(teamRepo, 'docs', 'codebase.md'); - try { - const existing = await fs.readFile(codebasePath, 'utf-8'); - const updated = await applyCodebaseSuggestions(existing, suggestions); - if (!dryRun) { - await fs.writeFile(codebasePath, updated, 'utf-8'); - } - log.success('Codebase.md 已更新'); - changedFiles.push('docs/codebase.md'); - } catch { - log.warn('docs/codebase.md 不存在或读取失败,跳过 codebase 更新'); - } + log.debug('Codebase suggestions (direct mode): 图谱变更已在 comment/write 阶段处理,跳过 AI 重写'); } else { // pending-review 模式 for (const s of suggestions) { @@ -243,15 +239,16 @@ export async function ciExtractMr(opts: CiExtractMrOptions): Promise { } // 执行 comment + // NOTE: codebase suggestions 不再作为独立 comment 发布,已被图谱变更 comment 替代 if (opts.mode === 'comment' || opts.mode === 'both') { if (opts.individualComments) { - const { posted } = await postIndividualComments(opts.url, learning, suggestions, opts.dryRun); + const { posted } = await postIndividualComments(opts.url, learning, undefined, opts.dryRun); log.success(`已发布 ${posted} 条独立建议 comment`); } else { const result = await postOrUpdateMrComment( opts.url, learning, - suggestions, + undefined, opts.commentMarker, opts.dryRun, ); @@ -266,6 +263,57 @@ export async function ciExtractMr(opts: CiExtractMrOptions): Promise { } } + // ── Codebase 图谱变更 ────────────────────────────────────── + let graphChangeSummary: { added: string[]; removed: string[] } | undefined; + try { + const { collectCode, extractCodeFacts, buildCodeGraph } = await import('../wiki-engine/adapters/index.js'); + const { execFileSync } = await import('node:child_process'); + const businessRepo = process.cwd(); + + // 从 git 获取当前 MR/PR 的变更文件列表 + // 尝试多种方式,兼容 shallow clone(depth=1 时 HEAD~1 不存在) + let changedFiles: string[] = []; + const diffCommands = [ + ['diff', '--name-only', 'HEAD~1', 'HEAD'], + ['show', '--name-only', '--format=', 'HEAD'], + ['diff', '--name-only', 'origin/master...HEAD'], + ]; + for (const args of diffCommands) { + try { + const diffOutput = execFileSync( + 'git', args, + { cwd: businessRepo, encoding: 'utf-8', timeout: 10_000 }, + ); + changedFiles = diffOutput.trim().split('\n') + .filter(f => f && /\.(ts|tsx|js|jsx|py|go|rs|java)$/.test(f)); + if (changedFiles.length > 0) break; + } catch { + continue; + } + } + if (changedFiles.length === 0) { + log.debug('[codebase-graph] 所有 git diff 方式均失败或无源文件变更'); + } + + if (changedFiles.length > 0) { + const { files } = await collectCode({ root: businessRepo, changedFiles, maxFiles: 50 }); + if (files.length > 0) { + const facts = extractCodeFacts(files); + const graph = buildCodeGraph(facts); + graphChangeSummary = { + added: graph.nodes.map(n => `\`${n.kind}:${n.label}\` ← ${n.file}`), + removed: [], + }; + + if ((opts.mode === 'comment' || opts.mode === 'both') && graphChangeSummary.added.length > 0) { + await postCodebaseGraphComment(opts.url, graphChangeSummary, opts.dryRun); + } + } + } + } catch (err) { + log.debug(`[codebase-graph] 图谱变更提取失败(非阻塞): ${err instanceof Error ? err.message : err}`); + } + // 执行 write if (opts.mode === 'write' || opts.mode === 'both') { // 当使用 individual comments 时,读取 rejection 状态进行过滤 @@ -296,6 +344,43 @@ export async function ciExtractMr(opts: CiExtractMrOptions): Promise { } } + // ── 图谱变更写入 team-repo/teamwiki/ ─────────────────── + let graphWritten = false; + if (graphChangeSummary && graphChangeSummary.added.length > 0 && !opts.dryRun) { + let graphRejected = false; + if (opts.individualComments) { + const parsed = parseMrUrl(opts.url); + const rejections = await readRejections(opts.url); + if (!shouldWrite('codebase-graph', rejections, parsed.provider)) { + graphRejected = true; + log.info('Codebase 图谱变更被 reject,跳过写入'); + } + } + + if (!graphRejected) { + try { + const { extractCodebase } = await import('../codebase-extract.js'); + const businessRepo = process.cwd(); + const parsed = parseMrUrl(opts.url); + const projectName = parsed.repo; + + await extractCodebase({ path: businessRepo, project: projectName }); + + const fse = await import('fs-extra'); + const srcWiki = path.join(businessRepo, 'teamwiki'); + const teamWikiRoot = path.join(path.resolve(opts.teamRepo!), 'teamwiki'); + if (await fse.pathExists(srcWiki)) { + await fse.copy(srcWiki, teamWikiRoot, { overwrite: true }); + await fse.remove(srcWiki).catch(() => {}); + graphWritten = true; + log.success(`teamwiki/ 图谱已更新到团队仓库`); + } + } catch (err) { + log.debug(`[codebase-graph] 图谱写入失败(非阻塞): ${err instanceof Error ? err.message : err}`); + } + } + } + await writeKnowledgeToRepo( opts.teamRepo!, filteredLearning, @@ -303,6 +388,7 @@ export async function ciExtractMr(opts: CiExtractMrOptions): Promise { opts.writeMode ?? 'direct', opts.url, opts.dryRun, + graphWritten, ); } diff --git a/src/ci/mr-comment.ts b/src/ci/mr-comment.ts index d1affc5..700c635 100644 --- a/src/ci/mr-comment.ts +++ b/src/ci/mr-comment.ts @@ -505,3 +505,73 @@ export async function postIndividualComments( log.success(`已发布 ${posted} 条独立建议`); return { posted }; } + +// ─── Codebase Graph Change Comment ────────────────────── + +const CODEBASE_GRAPH_MARKER = ''; + +function formatGraphComment(summary: { added: string[]; removed: string[] }): string { + const lines: string[] = []; + lines.push('## 📊 Codebase 知识图谱变更'); + lines.push(''); + lines.push('本次 MR 触发了以下代码知识更新:'); + lines.push(''); + + if (summary.added.length > 0) { + lines.push(`### 新增节点 (${summary.added.length})`); + for (const item of summary.added.slice(0, 20)) { + lines.push(`- ${item}`); + } + if (summary.added.length > 20) { + lines.push(`- _...及另外 ${summary.added.length - 20} 项_`); + } + lines.push(''); + } + + if (summary.removed.length > 0) { + lines.push(`### 删除节点 (${summary.removed.length})`); + for (const item of summary.removed.slice(0, 10)) { + lines.push(`- ${item}`); + } + lines.push(''); + } + + lines.push('---'); + lines.push('> 👎 对本条 comment 添加 reaction 将阻止本次图谱更新写入团队知识库'); + lines.push(CODEBASE_GRAPH_MARKER); + return lines.join('\n'); +} + +export async function postCodebaseGraphComment( + mrUrl: string, + summary: { added: string[]; removed: string[] }, + dryRun?: boolean, +): Promise { + const body = formatGraphComment(summary); + const parsed = parseMrUrl(mrUrl); + + if (dryRun) { + log.info('[dry-run] Codebase graph comment:'); + console.log(body); + return; + } + + if (parsed.provider === 'github') { + const existing = await findGitHubComment(parsed.owner, parsed.repo, parsed.number, CODEBASE_GRAPH_MARKER); + if (existing) { + await updateGitHubComment(parsed.owner, parsed.repo, existing.id, body); + } else { + await postGitHubComment(parsed.owner, parsed.repo, parsed.number, body); + } + } else { + const projectId = encodeURIComponent(`${parsed.owner}/${parsed.repo}`); + const mrGlobalId = await getMrGlobalId(projectId, parsed.number); + const existing = await findTGitComment(projectId, mrGlobalId, CODEBASE_GRAPH_MARKER); + if (existing) { + await updateTGitComment(projectId, mrGlobalId, existing.id, body); + } else { + await postTGitComment(projectId, mrGlobalId, body); + } + } + log.success('Codebase 图谱变更 comment 已发布'); +} diff --git a/src/code-knowledge-recall.ts b/src/code-knowledge-recall.ts new file mode 100644 index 0000000..68d359c --- /dev/null +++ b/src/code-knowledge-recall.ts @@ -0,0 +1,273 @@ +/** + * Graph-aware codebase knowledge recall (BM25 + graph-boost). + * + * Recall algorithm based on Team Wiki's wiki-query design by @lurkacai. + * Implements scored mode with graph neighbor boosting. + */ + +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +import type { CodeGraphIndex } from './wiki-engine/adapters/index.js'; + +export interface CodeKnowledgeResult { + page: string; + title: string; + score: number; + snippet: string; + kind: 'codebase'; +} + +interface CorpusStats { + totalDocs: number; + avgDocLength: number; + df: Map; +} + +interface PageDoc { + path: string; + title: string; + content: string; + tokens: string[]; +} + +const BM25_K1 = 1.5; +const BM25_B = 0.75; +const TITLE_BOOST = 3.0; +const RELATION_WEIGHT: Record = { imports: 3, mentions: 1, contains: 1 }; +const ENTRY_NODE_BOOST = 8; + +function tokenize(text: string): string[] { + const tokens: string[] = []; + const lower = text.toLowerCase(); + const words = lower.split(/[^a-z0-9一-鿿]+/).filter((w) => w.length >= 2); + for (const w of words) { + tokens.push(w); + } + return [...new Set(tokens)]; +} + +function countOccurrences(text: string, token: string): number { + let count = 0; + let idx = 0; + const lower = text.toLowerCase(); + while (true) { + idx = lower.indexOf(token, idx); + if (idx === -1) break; + count++; + idx += token.length; + } + return count; +} + +function buildCorpusStats(pages: PageDoc[]): CorpusStats { + const df = new Map(); + let totalLength = 0; + + for (const page of pages) { + totalLength += page.tokens.length; + const seen = new Set(); + for (const token of page.tokens) { + if (!seen.has(token)) { + seen.add(token); + df.set(token, (df.get(token) ?? 0) + 1); + } + } + } + + return { + totalDocs: pages.length, + avgDocLength: pages.length > 0 ? totalLength / pages.length : 1, + df, + }; +} + +function scoreBM25(page: PageDoc, queryTokens: string[], stats: CorpusStats): number { + let score = 0; + const dl = page.tokens.length; + const { totalDocs, avgDocLength, df } = stats; + + for (const token of queryTokens) { + const docFreq = df.get(token) ?? 0; + const idf = Math.log((totalDocs - docFreq + 0.5) / (docFreq + 0.5) + 1); + const tf = countOccurrences(page.content, token); + const tfNorm = (tf * (BM25_K1 + 1)) / (tf + BM25_K1 * (1 - BM25_B + BM25_B * dl / avgDocLength)); + const titleHit = page.title.toLowerCase().includes(token) ? TITLE_BOOST : 0; + score += idf * (tfNorm + titleHit); + } + + return score; +} + +function findEntryNodes(queryTokens: string[], graph: CodeGraphIndex): Set { + const entries = new Set(); + for (const node of graph.nodes) { + const text = `${node.id} ${node.label}`.toLowerCase(); + for (const token of queryTokens) { + if (token.length > 1 && text.includes(token)) { + entries.add(node.file); + break; + } + } + } + return entries; +} + +function computeGraphBoost(pagePath: string, entryNodes: Set, graph: CodeGraphIndex): number { + if (entryNodes.has(pagePath)) return ENTRY_NODE_BOOST; + + let maxBoost = 0; + for (const edge of graph.edges) { + let isNeighbor = false; + if (edge.from === pagePath && entryNodes.has(edge.to)) isNeighbor = true; + if (edge.to === pagePath && entryNodes.has(edge.from)) isNeighbor = true; + + if (isNeighbor) { + const relWeight = RELATION_WEIGHT[edge.relation] ?? 1; + const boost = relWeight * 0.8; + if (boost > maxBoost) maxBoost = boost; + } + } + return maxBoost; +} + +function extractSnippet(content: string, queryTokens: string[], maxLen: number = 300): string { + const lower = content.toLowerCase(); + let bestIdx = 0; + for (const token of queryTokens) { + const idx = lower.indexOf(token); + if (idx >= 0) { + bestIdx = idx; + break; + } + } + const start = Math.max(0, bestIdx - 50); + const end = Math.min(content.length, start + maxLen); + let snippet = content.slice(start, end).replace(/\n+/g, ' ').trim(); + if (start > 0) snippet = '...' + snippet; + if (end < content.length) snippet += '...'; + return snippet; +} + +async function loadWikiPages(wikiRoot: string): Promise { + const evidenceDir = path.join(wikiRoot, 'evidence', 'code'); + const pages: PageDoc[] = []; + + let projects: string[]; + try { + projects = await readdir(evidenceDir); + } catch { + return pages; + } + + for (const project of projects) { + const projectDir = path.join(evidenceDir, project); + let files: string[]; + try { + files = await readdir(projectDir); + } catch { + continue; + } + for (const file of files) { + if (!file.endsWith('.md')) continue; + try { + const filePath = path.join(projectDir, file); + const content = await readFile(filePath, 'utf-8'); + const titleMatch = content.match(/^title:\s*(.+)$/m); + const title = titleMatch ? titleMatch[1].trim() : file.replace('.md', ''); + pages.push({ + path: `evidence/code/${project}/${file}`, + title, + content, + tokens: tokenize(content), + }); + } catch { + continue; + } + } + } + + return pages; +} + +async function loadGraphIndex(wikiRoot: string): Promise { + const graphPath = path.join(wikiRoot, '.indices', 'graph-index.json'); + try { + const raw = await readFile(graphPath, 'utf-8'); + return JSON.parse(raw) as CodeGraphIndex; + } catch { + return null; + } +} + +export interface QueryCodeKnowledgeOptions { + wikiRoot: string; + limit?: number; + depth?: 'route' | 'context' | 'lookup'; +} + +export async function queryCodeKnowledge( + query: string, + options: QueryCodeKnowledgeOptions, +): Promise { + const { wikiRoot, limit = 5, depth = 'context' } = options; + + const pages = await loadWikiPages(wikiRoot); + if (pages.length === 0) return []; + + const graph = await loadGraphIndex(wikiRoot); + const queryTokens = tokenize(query); + if (queryTokens.length === 0) return []; + + const stats = buildCorpusStats(pages); + const entryNodes = graph ? findEntryNodes(queryTokens, graph) : new Set(); + + const scored: Array<{ page: PageDoc; score: number }> = []; + for (const page of pages) { + let score = scoreBM25(page, queryTokens, stats); + if (graph) { + const pageFile = page.path.replace(/^evidence\/code\/[^/]+\//, '').replace('.md', ''); + score += computeGraphBoost(pageFile, entryNodes, graph); + } + if (score > 0) { + scored.push({ page, score }); + } + } + + scored.sort((a, b) => b.score - a.score); + + const TOKEN_BUDGET: Record = { route: 500, context: 5000, lookup: 3000 }; + const budget = TOKEN_BUDGET[depth] ?? 5000; + const estimateTokens = (text: string) => Math.ceil(text.length / 3.5); + + const results: CodeKnowledgeResult[] = []; + let tokenUsed = 0; + + for (const { page, score } of scored) { + if (results.length >= limit) break; + + let snippet: string; + if (depth === 'route') { + snippet = page.title; + } else if (depth === 'lookup' && results.length === 0) { + const maxChars = Math.floor(budget * 3.5 * 0.7); + snippet = page.content.slice(0, maxChars); + } else { + snippet = extractSnippet(page.content, queryTokens); + } + + const cost = estimateTokens(page.title + ' ' + snippet); + if (tokenUsed + cost > budget && results.length > 0) break; + tokenUsed += cost; + + results.push({ + page: page.path, + title: page.title, + score, + snippet, + kind: 'codebase', + }); + } + + return results; +} diff --git a/src/codebase-cmd.ts b/src/codebase-cmd.ts index 2633fa8..c226106 100644 --- a/src/codebase-cmd.ts +++ b/src/codebase-cmd.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import chalk from 'chalk'; import type { GlobalOptions } from './types.js'; @@ -13,11 +15,16 @@ import type { Severity, LintReport, FixResult } from './codebase-lint.js'; export interface CodebaseCmdOptions extends GlobalOptions { lint?: boolean; fix?: boolean; + extract?: boolean | string; + incremental?: boolean; + upgradeWiki?: boolean; severity?: Severity; staleDays?: string; pendingReviewThreshold?: string; json?: boolean; output?: string; + project?: string; + maxFiles?: string; } // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -57,10 +64,31 @@ function hasHighIssues(report: LintReport): boolean { export async function codebaseCmd(opts: CodebaseCmdOptions): Promise { const cwd = process.cwd(); + if (opts.upgradeWiki) { + const { upgradeCodebaseWiki } = await import('./codebase-upgrade-wiki.js'); + await upgradeCodebaseWiki({ cwd, dryRun: opts.dryRun, json: opts.json }); + return; + } + + if (opts.extract) { + const { extractCodebase } = await import('./codebase-extract.js'); + const extractPath = typeof opts.extract === 'string' ? opts.extract : cwd; + await extractCodebase({ + path: extractPath, + incremental: opts.incremental, + json: opts.json, + project: opts.project, + maxFiles: opts.maxFiles ? parseInt(opts.maxFiles, 10) : undefined, + }); + return; + } + if (!opts.lint) { console.log('teamai codebase — 团队 codebase 文档健康度管理'); console.log(''); console.log('用法:'); + console.log(' teamai codebase --extract [path] 提取代码知识 + 构建图谱'); + console.log(' teamai codebase --extract --incremental 增量模式'); console.log(' teamai codebase --lint 运行全局一致性检查'); console.log(' teamai codebase --lint --fix 检查并自动修复低风险问题'); console.log(' teamai codebase --lint --json 输出 JSON 报告(适合 CI)'); @@ -68,9 +96,24 @@ export async function codebaseCmd(opts: CodebaseCmdOptions): Promise { return; } - const staleDays = opts.staleDays ? parseInt(opts.staleDays, 10) : 60; + // 若 teamwiki/ 存在,优先使用图谱 lint + const { pathExists } = await import('./utils/fs.js'); + const teamwikiDir = path.join(cwd, 'teamwiki'); + if (await pathExists(teamwikiDir)) { + const { lintTeamwiki, formatWikiLintReport } = await import('./codebase-wiki-lint.js'); + const report = await lintTeamwiki({ cwd, severity: opts.severity as 'high' | 'medium' | 'low' | 'info' }); + if (opts.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(formatWikiLintReport(report)); + } + if (report.summary.high > 0) process.exitCode = 1; + return; + } + + const staleDays = opts.staleDays ? (parseInt(opts.staleDays, 10) || 60) : 60; const pendingThreshold = opts.pendingReviewThreshold - ? parseInt(opts.pendingReviewThreshold, 10) + ? (parseInt(opts.pendingReviewThreshold, 10) || 10) : 10; const severity = opts.severity ?? 'info'; diff --git a/src/codebase-extract.ts b/src/codebase-extract.ts new file mode 100644 index 0000000..6f4a02a --- /dev/null +++ b/src/codebase-extract.ts @@ -0,0 +1,382 @@ +/** + * Codebase knowledge extraction and graph building. + * + * Knowledge graph architecture and wiki protocol based on Team Wiki + * by @lurkacai. Core concepts: structured code facts, graph-index, + * evidence pages, router/hot/index navigation, and gaps detection. + */ + +import { mkdir, writeFile, readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import chalk from 'chalk'; + +import { + collectCode, + extractCodeFacts, + buildCodeGraph, + detectCodeIncrementalChanges, +} from './wiki-engine/adapters/index.js'; +import type { CodeFact, CodeGraphIndex } from './wiki-engine/adapters/index.js'; + +export interface ExtractCodebaseOptions { + path?: string; + incremental?: boolean; + json?: boolean; + project?: string; + maxFiles?: number; +} + +interface ExtractResult { + project: string; + filesScanned: number; + facts: { total: number; byKind: Record }; + graph: { nodes: number; edges: number }; + incremental: boolean; + outputDir: string; +} + +interface KnowledgeGap { + id: string; + kind: string; + description: string; + source: string; +} + +function detectKnowledgeGaps( + facts: CodeFact[], + graph: CodeGraphIndex, + files: Array<{ relativePath: string }>, +): KnowledgeGap[] { + const gaps: KnowledgeGap[] = []; + const scannedFiles = new Set(files.map((f) => f.relativePath)); + const nodeFiles = new Set(graph.nodes.map((n) => n.file)); + const connectedNodes = new Set(); + for (const edge of graph.edges) { + connectedNodes.add(edge.from); + connectedNodes.add(edge.to); + } + + // 1. 未解析的外部依赖:import target 不在扫描范围内 + const relationFacts = facts.filter((f) => f.kind === 'relation'); + const unresolvedImports = new Set(); + for (const rel of relationFacts) { + const target = rel.name; + if (target.startsWith('.')) continue; // 相对路径跳过 + if (target.startsWith('node:')) continue; // Node 内置模块跳过 + const matchesAnyFile = [...scannedFiles].some((f) => f.includes(target.replace(/\//g, path.sep))); + if (!matchesAnyFile) { + unresolvedImports.add(target); + } + } + if (unresolvedImports.size > 5) { + gaps.push({ + id: 'unresolved-external-deps', + kind: 'EXTERNAL_DEP_UNDOCUMENTED', + description: `${unresolvedImports.size} 个外部依赖未在知识库中记录(如 ${[...unresolvedImports].slice(0, 3).join(', ')})`, + source: 'relation facts', + }); + } + + // 2. 接口无实现:有 interface 声明但图谱中无 IMPLEMENTS 边指向它 + const interfaces = facts.filter((f) => f.kind === 'interface'); + const components = facts.filter((f) => f.kind === 'component'); + const componentNames = new Set(components.map((c) => c.name.toLowerCase())); + const unimplemented: string[] = []; + for (const iface of interfaces) { + const name = iface.name.toLowerCase(); + const hasImpl = componentNames.has(name) || + componentNames.has(name.replace(/^i/, '').toLowerCase()) || + componentNames.has((name + 'impl').toLowerCase()); + if (!hasImpl) { + unimplemented.push(iface.name); + } + } + if (unimplemented.length > 3) { + gaps.push({ + id: 'interface-no-impl', + kind: 'IMPL_MISSING', + description: `${unimplemented.length} 个接口未发现对应实现(如 ${unimplemented.slice(0, 3).join(', ')})`, + source: 'interface facts', + }); + } + + // 3. 孤立组件:有节点但与图谱中其他节点无任何连接 + const orphanNodes = graph.nodes.filter( + (n) => !connectedNodes.has(n.id) && !connectedNodes.has(n.file), + ); + if (orphanNodes.length > 5 && orphanNodes.length > graph.nodes.length * 0.3) { + gaps.push({ + id: 'high-orphan-ratio', + kind: 'LOW_CONNECTIVITY', + description: `${orphanNodes.length}/${graph.nodes.length} 个节点无图谱连接,依赖关系可能未被完整提取`, + source: 'graph-index.json', + }); + } + + // 4. 无错误处理模式:有组件但无 error 类型定义 + const errorFacts = facts.filter((f) => f.kind === 'error'); + if (components.length > 10 && errorFacts.length === 0) { + gaps.push({ + id: 'no-error-patterns', + kind: 'ERROR_HANDLING_UNDOCUMENTED', + description: `项目有 ${components.length} 个组件但未检测到错误类型定义,错误处理模式可能未文档化`, + source: 'code scan', + }); + } + + // 5. 无配置项目:有组件但无 config/env 提取 + const configFacts = facts.filter((f) => f.kind === 'config'); + if (components.length > 10 && configFacts.length === 0) { + gaps.push({ + id: 'no-config-detected', + kind: 'CONFIG_UNDOCUMENTED', + description: `项目有 ${components.length} 个组件但未检测到配置项/环境变量,配置管理可能未文档化`, + source: 'code scan', + }); + } + + return gaps; +} + +function buildEvidencePages(facts: CodeFact[], project: string): Map { + const pages = new Map(); + const byKind = new Map(); + + for (const fact of facts) { + if (fact.kind === 'relation') continue; + const existing = byKind.get(fact.kind) ?? []; + existing.push(fact); + byKind.set(fact.kind, existing); + } + + for (const [kind, kindFacts] of byKind) { + const lines = [ + '---', + `title: ${project} ${kind}`, + 'domain: code-knowledge', + `source:`, + ...Array.from(new Set(kindFacts.map((f) => f.file))).map((f) => ` - ${f}`), + '---', + '', + `# ${kind.charAt(0).toUpperCase() + kind.slice(1)}`, + '', + ]; + + for (const fact of kindFacts) { + lines.push(`- \`${fact.name}\` ← ${fact.file}:${fact.lineStart} [${fact.confidence}]`); + if (fact.detail) { + lines.push(` \`\`\`\n ${fact.detail.trim()}\n \`\`\``); + } + } + + pages.set(`${kind}.md`, lines.join('\n')); + } + + const relationFacts = facts.filter((f) => f.kind === 'relation'); + if (relationFacts.length > 0) { + const byDir = new Map(); + for (const fact of relationFacts) { + const seg = fact.file.split('/')[0] || '_root'; + const existing = byDir.get(seg) ?? []; + existing.push(fact); + byDir.set(seg, existing); + } + for (const [seg, segFacts] of byDir) { + const lines = [ + '---', + `title: ${project} relations (${seg})`, + 'domain: code-knowledge', + '---', + '', + `# Relations (${seg})`, + '', + ]; + for (const fact of segFacts) { + lines.push(`- \`${fact.name}\` ← ${fact.file}:${fact.lineStart}`); + } + pages.set(`relation-${seg}.md`, lines.join('\n')); + } + } + + const indexLines = [ + '---', + `title: ${project} code knowledge index`, + 'domain: code-knowledge', + '---', + '', + `# ${project}`, + '', + `Facts: ${facts.length} | Pages: ${pages.size}`, + '', + '## Pages', + '', + ]; + for (const pageName of pages.keys()) { + indexLines.push(`- [${pageName}](./${pageName})`); + } + pages.set('index.md', indexLines.join('\n')); + + return pages; +} + +export async function extractCodebase(opts: ExtractCodebaseOptions): Promise { + const root = path.resolve(opts.path || '.'); + const project = opts.project || path.basename(root); + const maxFiles = opts.maxFiles || 200; + + const wikiRoot = path.join(root, 'teamwiki'); + const evidenceDir = path.join(wikiRoot, 'evidence', 'code', project); + const indicesDir = path.join(wikiRoot, '.indices'); + const manifestPath = path.join(wikiRoot, 'source-manifest.json'); + + let changedFiles: string[] | undefined; + if (opts.incremental) { + try { + const changes = await detectCodeIncrementalChanges(root, manifestPath, project); + if (changes.added.length === 0 && changes.changed.length === 0 && changes.deleted.length === 0) { + if (opts.json) { + console.log(JSON.stringify({ status: 'up-to-date', project })); + } else { + console.log(chalk.green(`[extract] ${project}: 无变更,跳过。`)); + } + return; + } + changedFiles = [...changes.added, ...changes.changed]; + if (!opts.json) { + console.log(chalk.dim(`[extract] 增量模式:${changedFiles.length} 文件变更`)); + } + } catch { + if (!opts.json) { + console.log(chalk.dim('[extract] 无历史 manifest,执行全量提取')); + } + } + } + + const { files } = await collectCode({ root, maxFiles, changedFiles }); + if (files.length === 0) { + if (opts.json) { + console.log(JSON.stringify({ status: 'no-files', project })); + } else { + console.log(chalk.yellow(`[extract] ${project}: 未发现可提取的源代码文件。`)); + } + return; + } + + const facts = extractCodeFacts(files); + const graph: CodeGraphIndex = buildCodeGraph(facts); + + const pages = buildEvidencePages(facts, project); + + await mkdir(evidenceDir, { recursive: true }); + await mkdir(indicesDir, { recursive: true }); + + for (const [filename, content] of pages) { + await writeFile(path.join(evidenceDir, filename), content, 'utf-8'); + } + + await writeFile( + path.join(indicesDir, 'graph-index.json'), + JSON.stringify(graph, null, 2), + 'utf-8', + ); + + // 生成 team-wiki 标准入口文件 + const routerMd = [ + '# Team Wiki Router', + '', + 'Route broad questions to the relevant domain entrypoint.', + '', + `- [[code/${project}/index]] — ${project} 代码知识`, + '', + ].join('\n'); + await writeFile(path.join(wikiRoot, 'router.md'), routerMd, 'utf-8'); + + const hotMd = [ + '# Hot Context', + '', + 'Keep only active working memory here: current focus, recent decisions, open questions.', + 'Move durable conclusions into domain pages.', + '', + ].join('\n'); + await writeFile(path.join(wikiRoot, 'hot.md'), hotMd, 'utf-8'); + + const wikiIndexMd = [ + '# Team Wiki Index', + '', + `Last updated: ${new Date().toISOString()}`, + '', + '## Domains', + '', + `- [${project}](./evidence/code/${project}/index.md) — 代码知识图谱`, + '', + '## Navigation', + '', + '- [router.md](./router.md) — 领域路由入口', + '- [hot.md](./hot.md) — 活跃工作记忆', + '', + ].join('\n'); + await writeFile(path.join(wikiRoot, 'index.md'), wikiIndexMd, 'utf-8'); + + // 生成 gaps/ — 知识缺口追踪 + const gaps = detectKnowledgeGaps(facts, graph, files); + const gapsDir = path.join(wikiRoot, 'gaps'); + await mkdir(gapsDir, { recursive: true }); + const gapLines = [ + '---', + 'title: Knowledge Gaps', + `domain: ${project}`, + 'source: []', + '---', + '', + '# Knowledge Gaps', + '', + '在代码知识提取过程中发现的缺口。这些条目表示知识库尚未覆盖的领域,recall 命中 gap 时不应凭空回答。', + '', + '| ID | Kind | Status | Description | Source |', + '|----|------|--------|-------------|--------|', + ]; + for (const gap of gaps) { + gapLines.push(`| ${gap.id} | ${gap.kind} | open | ${gap.description} | ${gap.source} |`); + } + if (gaps.length === 0) { + gapLines.push('| — | — | — | 未发现明显知识缺口 | — |'); + } + gapLines.push(''); + await writeFile(path.join(gapsDir, 'detected.md'), gapLines.join('\n'), 'utf-8'); + + const manifest = { + version: 1, + lastScan: new Date().toISOString(), + files: files.map((f) => ({ + relativePath: f.relativePath, + sha256: f.sha256, + language: f.language, + })), + }; + await writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8'); + + const byKind: Record = {}; + for (const fact of facts) { + byKind[fact.kind] = (byKind[fact.kind] ?? 0) + 1; + } + + const result: ExtractResult = { + project, + filesScanned: files.length, + facts: { total: facts.length, byKind }, + graph: { nodes: graph.nodes.length, edges: graph.edges.length }, + incremental: !!opts.incremental && !!changedFiles, + outputDir: wikiRoot, + }; + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(chalk.green(`[extract] ${project} 完成`)); + console.log(` 文件: ${result.filesScanned}`); + console.log(` 事实: ${result.facts.total} (${Object.entries(byKind).map(([k, v]) => `${k}:${v}`).join(', ')})`); + console.log(` 图谱: ${result.graph.nodes} nodes, ${result.graph.edges} edges`); + console.log(` 输出: ${wikiRoot}`); + } +} diff --git a/src/codebase-upgrade-wiki.ts b/src/codebase-upgrade-wiki.ts new file mode 100644 index 0000000..32903d5 --- /dev/null +++ b/src/codebase-upgrade-wiki.ts @@ -0,0 +1,116 @@ +import { readdir, readFile, rm } from 'node:fs/promises'; +import path from 'node:path'; + +import chalk from 'chalk'; +import matter from 'gray-matter'; + +import { extractCodebase } from './codebase-extract.js'; +import { log } from './utils/logger.js'; +import { pathExists } from './utils/fs.js'; + +export interface UpgradeCodebaseWikiOptions { + cwd: string; + dryRun?: boolean; + json?: boolean; +} + +interface MigrationResult { + migrated: string[]; + skipped: string[]; + errors: string[]; +} + +export async function upgradeCodebaseWiki(opts: UpgradeCodebaseWikiOptions): Promise { + const teamCodebaseDir = path.join(opts.cwd, 'docs', 'team-codebase', 'repos'); + + if (!await pathExists(teamCodebaseDir)) { + if (opts.json) { + console.log(JSON.stringify({ status: 'nothing-to-migrate', reason: 'docs/team-codebase/repos/ not found' })); + } else { + log.info('未发现 docs/team-codebase/repos/ 目录,无需迁移。'); + } + return; + } + + const files = await readdir(teamCodebaseDir); + const mdFiles = files.filter(f => f.endsWith('.md')); + + if (mdFiles.length === 0) { + if (opts.json) { + console.log(JSON.stringify({ status: 'nothing-to-migrate', reason: 'no .md files in repos/' })); + } else { + log.info('repos/ 下无 .md 文件,无需迁移。'); + } + return; + } + + if (!opts.json) { + log.info(`发现 ${mdFiles.length} 个旧格式仓库文档,开始迁移到 teamwiki/ 图谱格式...`); + } + + const result: MigrationResult = { migrated: [], skipped: [], errors: [] }; + + for (const file of mdFiles) { + const slug = file.replace('.md', ''); + const filePath = path.join(teamCodebaseDir, file); + + try { + const content = await readFile(filePath, 'utf-8'); + const parsed = matter(content); + const source = parsed.data['source'] ?? parsed.data['repo_url']; + + if (!source) { + result.skipped.push(`${slug}: 无 source/repo_url 字段`); + continue; + } + + if (opts.dryRun) { + result.migrated.push(`${slug} → teamwiki/evidence/code/${slug}/`); + continue; + } + + // 尝试从缓存目录查找已有 clone + const cacheBase = path.join(process.env['HOME'] ?? '', '.teamai', 'cache', 'repos'); + const urlParts = String(source).replace(/^https?:\/\//, '').replace(/@.*$/, '').split('/'); + const cachePath = path.join(cacheBase, ...urlParts.slice(0, 3)); + + if (await pathExists(cachePath)) { + await extractCodebase({ path: cachePath, project: slug }); + result.migrated.push(slug); + } else { + result.skipped.push(`${slug}: 缓存不存在 (${cachePath}), 请先执行 teamai import --from-repo`); + } + } catch (err) { + result.errors.push(`${slug}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + if (opts.json) { + console.log(JSON.stringify({ status: 'done', ...result }, null, 2)); + } else { + if (result.migrated.length > 0) { + log.success(`已迁移 ${result.migrated.length} 个仓库到 teamwiki/ 格式`); + for (const m of result.migrated) { + console.log(chalk.green(` ✓ ${m}`)); + } + } + if (result.skipped.length > 0) { + console.log(chalk.yellow(`跳过 ${result.skipped.length} 个:`)); + for (const s of result.skipped) { + console.log(chalk.yellow(` - ${s}`)); + } + } + if (result.errors.length > 0) { + console.log(chalk.red(`失败 ${result.errors.length} 个:`)); + for (const e of result.errors) { + console.log(chalk.red(` ✗ ${e}`)); + } + } + + if (!opts.dryRun && result.migrated.length > 0) { + log.info(''); + log.info('迁移完成。旧的 docs/team-codebase/ 目录已保留(未删除)。'); + log.info('确认新图谱工作正常后,可手动删除 docs/team-codebase/ 目录。'); + } + } +} diff --git a/src/codebase-wiki-lint.ts b/src/codebase-wiki-lint.ts new file mode 100644 index 0000000..979a688 --- /dev/null +++ b/src/codebase-wiki-lint.ts @@ -0,0 +1,250 @@ +import { readFile, readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; + +import chalk from 'chalk'; + +import { pathExists } from './utils/fs.js'; +import type { CodeGraphIndex } from './wiki-engine/adapters/index.js'; + +export type WikiLintSeverity = 'high' | 'medium' | 'low' | 'info'; + +export interface WikiLintIssue { + severity: WikiLintSeverity; + category: string; + location: string; + message: string; +} + +export interface WikiLintReport { + issues: WikiLintIssue[]; + summary: { + total: number; + high: number; + medium: number; + low: number; + info: number; + }; + graphHealth: { + nodeCount: number; + edgeCount: number; + orphanNodes: number; + connectivity: number; + }; +} + +export async function lintTeamwiki(opts: { + cwd: string; + severity?: WikiLintSeverity; +}): Promise { + const wikiRoot = path.join(opts.cwd, 'teamwiki'); + const issues: WikiLintIssue[] = []; + const minSeverity = opts.severity ?? 'info'; + const severityOrder: WikiLintSeverity[] = ['info', 'low', 'medium', 'high']; + const minIdx = severityOrder.indexOf(minSeverity); + + function addIssue(issue: WikiLintIssue): void { + if (severityOrder.indexOf(issue.severity) >= minIdx) { + issues.push(issue); + } + } + + // Check graph-index.json exists + const graphPath = path.join(wikiRoot, '.indices', 'graph-index.json'); + let graph: CodeGraphIndex | null = null; + + if (!await pathExists(graphPath)) { + addIssue({ + severity: 'high', + category: 'graph-missing', + location: 'teamwiki/.indices/graph-index.json', + message: 'graph-index.json 不存在,知识图谱未构建', + }); + } else { + try { + const raw = await readFile(graphPath, 'utf-8'); + graph = JSON.parse(raw) as CodeGraphIndex; + } catch { + addIssue({ + severity: 'high', + category: 'graph-corrupt', + location: graphPath, + message: 'graph-index.json 解析失败', + }); + } + } + + // Check evidence directory + const evidenceDir = path.join(wikiRoot, 'evidence', 'code'); + if (!await pathExists(evidenceDir)) { + addIssue({ + severity: 'high', + category: 'evidence-missing', + location: 'teamwiki/evidence/code/', + message: 'evidence 目录不存在,无代码事实页', + }); + } else { + const projects = await readdir(evidenceDir); + if (projects.length === 0) { + addIssue({ + severity: 'medium', + category: 'evidence-empty', + location: 'teamwiki/evidence/code/', + message: 'evidence 目录为空,未提取任何项目', + }); + } + + for (const project of projects) { + const projectDir = path.join(evidenceDir, project); + const pStat = await stat(projectDir).catch(() => null); + if (!pStat?.isDirectory()) { + if (!pStat) { + addIssue({ severity: 'low', category: 'stat-failed', location: `evidence/code/${project}`, message: '无法读取目录状态' }); + } + continue; + } + + const files = await readdir(projectDir); + if (!files.includes('index.md')) { + addIssue({ + severity: 'low', + category: 'missing-index', + location: `evidence/code/${project}/`, + message: '缺少 index.md 总索引页', + }); + } + } + } + + // Check navigation files (router.md, index.md, hot.md) + for (const navFile of ['router.md', 'index.md', 'hot.md']) { + if (!await pathExists(path.join(wikiRoot, navFile))) { + addIssue({ + severity: 'low', + category: 'nav-missing', + location: `teamwiki/${navFile}`, + message: `导航文件 ${navFile} 不存在,知识库入口不完整`, + }); + } + } + + // Check source-manifest.json + const manifestPath = path.join(wikiRoot, 'source-manifest.json'); + if (!await pathExists(manifestPath)) { + addIssue({ + severity: 'low', + category: 'manifest-missing', + location: 'teamwiki/source-manifest.json', + message: 'source-manifest.json 不存在,增量更新不可用', + }); + } else { + try { + const raw = await readFile(manifestPath, 'utf-8'); + const manifest = JSON.parse(raw); + if (manifest.lastScan) { + const daysSince = (Date.now() - new Date(manifest.lastScan).getTime()) / (1000 * 60 * 60 * 24); + if (daysSince > 60) { + addIssue({ + severity: 'medium', + category: 'stale-manifest', + location: 'teamwiki/source-manifest.json', + message: `上次扫描距今 ${Math.floor(daysSince)} 天,建议重新执行 --extract`, + }); + } + } + } catch { + addIssue({ + severity: 'low', + category: 'manifest-corrupt', + location: manifestPath, + message: 'source-manifest.json 解析失败', + }); + } + } + + // Graph health metrics + let graphHealth = { nodeCount: 0, edgeCount: 0, orphanNodes: 0, connectivity: 0 }; + if (graph) { + const nodeIds = new Set(graph.nodes.map(n => n.id)); + const connectedNodes = new Set(); + for (const edge of graph.edges) { + connectedNodes.add(edge.from); + connectedNodes.add(edge.to); + } + const orphans = graph.nodes.filter(n => !connectedNodes.has(n.id) && !connectedNodes.has(n.file)); + const connectivity = graph.nodes.length > 0 + ? (graph.nodes.length - orphans.length) / graph.nodes.length + : 0; + + graphHealth = { + nodeCount: graph.nodes.length, + edgeCount: graph.edges.length, + orphanNodes: orphans.length, + connectivity: Math.round(connectivity * 100) / 100, + }; + + if (connectivity < 0.3) { + addIssue({ + severity: 'medium', + category: 'low-connectivity', + location: 'teamwiki/.indices/graph-index.json', + message: `图谱连通性 ${(connectivity * 100).toFixed(0)}% 过低(${orphans.length} 个孤立节点)`, + }); + } + + if (graph.edges.length === 0 && graph.nodes.length > 10) { + addIssue({ + severity: 'high', + category: 'no-edges', + location: 'teamwiki/.indices/graph-index.json', + message: `图谱有 ${graph.nodes.length} 个节点但 0 条边,图谱构建可能失败`, + }); + } + } + + const summary = { + total: issues.length, + high: issues.filter(i => i.severity === 'high').length, + medium: issues.filter(i => i.severity === 'medium').length, + low: issues.filter(i => i.severity === 'low').length, + info: issues.filter(i => i.severity === 'info').length, + }; + + return { issues, summary, graphHealth }; +} + +export function formatWikiLintReport(report: WikiLintReport): string { + const lines: string[] = []; + + lines.push(chalk.bold('=== teamwiki/ 知识图谱健康度检查 ===')); + lines.push(''); + lines.push(`图谱: ${report.graphHealth.nodeCount} nodes, ${report.graphHealth.edgeCount} edges, 连通性 ${(report.graphHealth.connectivity * 100).toFixed(0)}%`); + if (report.graphHealth.orphanNodes > 0) { + lines.push(chalk.dim(` (${report.graphHealth.orphanNodes} 个孤立节点)`)); + } + lines.push(''); + + if (report.issues.length === 0) { + lines.push(chalk.green('✓ 无问题')); + return lines.join('\n'); + } + + const byCategory = new Map(); + for (const issue of report.issues) { + const existing = byCategory.get(issue.category) ?? []; + existing.push(issue); + byCategory.set(issue.category, existing); + } + + for (const [category, categoryIssues] of byCategory) { + lines.push(chalk.bold(`[${category}] (${categoryIssues.length})`)); + for (const issue of categoryIssues) { + const sevColor = issue.severity === 'high' ? chalk.red + : issue.severity === 'medium' ? chalk.yellow : chalk.dim; + lines.push(` ${sevColor(`[${issue.severity}]`)} ${issue.location}: ${issue.message}`); + } + lines.push(''); + } + + lines.push(`总计: ${report.summary.high} high, ${report.summary.medium} medium, ${report.summary.low} low, ${report.summary.info} info`); + return lines.join('\n'); +} diff --git a/src/import-iwiki.ts b/src/import-iwiki.ts index 4275100..fbfc7c0 100644 --- a/src/import-iwiki.ts +++ b/src/import-iwiki.ts @@ -5,10 +5,14 @@ * 分类、审查、推送均复用 import-local.ts 的现有函数。 */ +import path from 'node:path'; +import { readFile, mkdir, writeFile } from 'node:fs/promises'; + import { classifyWithAI, interactiveReview, pushAccepted } from './import-local.js'; import { IWikiClient } from './utils/iwiki-client.js'; import type { IWikiDocument, IWikiPage } from './utils/iwiki-client.js'; import { log, spinner } from './utils/logger.js'; +import { pathExists } from './utils/fs.js'; // ─── 内部辅助函数 ────────────────────────────────────────────── @@ -193,5 +197,176 @@ export async function importFromIWiki(opts: { outputDir: opts.outputDir, }); + // 10. 与 teamwiki 代码知识建立 MAPS_TO 关系(在 push 之前,确保结果被推送) + const teamwikiRoot = path.join(repoPath, 'teamwiki'); + if (await pathExists(path.join(teamwikiRoot, '.indices', 'graph-index.json'))) { + try { + const mapsToEdges = await reconcileIwikiWithCodebase(documents, teamwikiRoot); + if (mapsToEdges.length > 0) { + log.success(`建立 ${mapsToEdges.length} 条 iWiki↔代码 MAPS_TO 关系`); + } else { + log.info('[reconcile] 未发现 iWiki 文档与代码知识的匹配关系(文档内容可能与代码无关)'); + } + } catch (err) { + log.debug(`[reconcile] iWiki↔代码关系建立失败(非阻塞): ${err instanceof Error ? err.message : err}`); + } + } + + // 11. 自动推送所有产物到团队仓库 + if (!opts.dryRun) { + try { + const { pushRepoDirectly } = await import('./utils/git.js'); + await pushRepoDirectly( + repoPath, + `[teamai] Import from iWiki: ${documents.map(d => d.title).slice(0, 3).join(', ')}`, + ['.'], + ); + log.success('已推送到团队仓库'); + } catch (err) { + log.debug(`[push] 自动推送失败(可手动 teamai push): ${err instanceof Error ? err.message : err}`); + } + } + log.success('iWiki 导入完成'); } + +// ─── iWiki↔Codebase Reconciliation ──────────────────────────── + +interface MapsToEdge { + from: string; + to: string; + relation: 'MAPS_TO'; + term: string; + confidence: number; +} + +/** + * 将 iWiki 文档与 teamwiki 代码知识图谱进行对账,建立 MAPS_TO 关系。 + * + * 基于 team-wiki reconciler 的核心逻辑(by @lurkacai): + * - 从文档中提取关键术语(API path、类名、模块名) + * - 在代码事实页面中搜索匹配 + * - 匹配成功则建立 MAPS_TO 边 + */ +async function reconcileIwikiWithCodebase( + documents: IWikiDocument[], + teamwikiRoot: string, +): Promise { + const graphPath = path.join(teamwikiRoot, '.indices', 'graph-index.json'); + const graphRaw = await readFile(graphPath, 'utf-8'); + const graph = JSON.parse(graphRaw); + + // 收集代码节点的标签用于匹配 + const codeLabels = new Map(); + for (const node of graph.nodes) { + codeLabels.set(node.label.toLowerCase(), node.id); + // 也索引 PascalCase 拆分后的单词 + const words = node.label.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase(); + codeLabels.set(words, node.id); + } + + // 加载代码事实页面内容用于全文匹配 + const evidenceDir = path.join(teamwikiRoot, 'evidence', 'code'); + const codePageContents = new Map(); + if (await pathExists(evidenceDir)) { + const { readdir } = await import('node:fs/promises'); + const projects = await readdir(evidenceDir); + for (const project of projects) { + const projectDir = path.join(evidenceDir, project); + const files = await readdir(projectDir).catch(() => [] as string[]); + for (const file of files) { + if (!file.endsWith('.md')) continue; + const content = await readFile(path.join(projectDir, file), 'utf-8').catch(() => ''); + codePageContents.set(`evidence/code/${project}/${file}`, content); + } + } + } + + const mapsToEdges: MapsToEdge[] = []; + const edgeSet = new Set(); + + for (const doc of documents) { + const docSlug = `iwiki/p/${doc.docid}`; + const terms = extractKeyTermsFromDoc(doc.content); + + for (const term of terms) { + // 方式 1:术语直接匹配代码节点标签 + const directMatch = codeLabels.get(term.toLowerCase()); + if (directMatch) { + const key = `${docSlug}|${directMatch}`; + if (!edgeSet.has(key)) { + edgeSet.add(key); + mapsToEdges.push({ from: docSlug, to: directMatch, relation: 'MAPS_TO', term, confidence: 0.8 }); + } + continue; + } + + // 方式 2:术语在代码事实页面全文中出现 + for (const [pagePath, content] of codePageContents) { + if (content.toLowerCase().includes(term.toLowerCase()) && term.length > 3) { + const key = `${docSlug}|${pagePath}`; + if (!edgeSet.has(key)) { + edgeSet.add(key); + mapsToEdges.push({ from: docSlug, to: pagePath, relation: 'MAPS_TO', term, confidence: 0.6 }); + } + break; // 每个术语最多匹配一个 code page + } + } + } + } + + // 写入 graph-index.json(去重:按 from+to+relation 三元组) + if (mapsToEdges.length > 0) { + const existingKeys = new Set( + graph.edges.map((e: { from: string; to: string; relation: string }) => `${e.from}|${e.to}|${e.relation}`), + ); + for (const edge of mapsToEdges) { + const key = `${edge.from}|${edge.to}|${edge.relation}`; + if (!existingKeys.has(key)) { + existingKeys.add(key); + graph.edges.push(edge); + } + } + await writeFile(graphPath, JSON.stringify(graph, null, 2), 'utf-8'); + } + + return mapsToEdges; +} + +/** + * 从文档内容中提取关键术语,用于与代码知识匹配。 + * + * 提取规则: + * - API 路径:/api/v1/xxx 形式 + * - 代码标识符:PascalCase 或 camelCase 标识符 + * - 反引号包裹的代码片段 + */ +function extractKeyTermsFromDoc(content: string): string[] { + const terms = new Set(); + + // API 路径 + const apiPaths = content.match(/\/api\/[a-z0-9/_-]+/gi); + if (apiPaths) { + for (const p of apiPaths) terms.add(p); + } + + // 反引号内的代码标识符(任意格式:PascalCase、camelCase、snake_case) + const codeRefs = content.matchAll(/`([a-zA-Z_][a-zA-Z0-9_]{2,})`/g); + for (const m of codeRefs) { + if (m[1]) terms.add(m[1]); + } + + // PascalCase 标识符(独立出现) + const pascalMatches = content.matchAll(/(?:^|[\s(,])([A-Z][a-z]+(?:[A-Z][a-z]+)+)/gm); + for (const m of pascalMatches) { + if (m[1]) terms.add(m[1]); + } + + // snake_case 标识符(2+ 段,如 user_token、create_session) + const snakeMatches = content.matchAll(/\b([a-z][a-z0-9]+(?:_[a-z0-9]+){1,})\b/g); + for (const m of snakeMatches) { + if (m[1] && m[1].length > 4) terms.add(m[1]); + } + + return [...terms]; +} diff --git a/src/import-repo.ts b/src/import-repo.ts index 8fc0bf3..8da2a77 100644 --- a/src/import-repo.ts +++ b/src/import-repo.ts @@ -3,6 +3,7 @@ import fs from 'fs-extra'; import chalk from 'chalk'; import { generateCodebaseMd } from './codebase.js'; +import { extractCodebase } from './codebase-extract.js'; import { mergeWithAnchors } from './section-patcher.js'; import { detectProvider } from './providers/registry.js'; import { shallowClone, shallowFetch } from './clone.js'; @@ -55,6 +56,87 @@ export interface ImportFromRepoOptions { incremental?: boolean; } +// ─── Cross-Repo Edge Detection ───────────────────────── + +interface SimpleGraphIndex { + nodes: Array<{ id: string; kind: string; label: string; file: string }>; + edges: Array<{ from: string; to: string; relation: string }>; +} + +/** + * 检测跨仓库依赖关系。 + * + * 通过比较两个图谱的节点标签(组件名/接口名), + * 当仓库 A 有一个节点名称与仓库 B 的节点名称匹配时, + * 说明两者可能存在依赖关系(如共享接口、同名组件引用)。 + * + * 基于 team-wiki 的 buildCodeGraphIndex 中 exportIndex 匹配思想。 + */ +function detectCrossRepoEdges( + overlay: SimpleGraphIndex, + existing: SimpleGraphIndex, + _newProject: string, +): Array<{ from: string; to: string; relation: string }> { + const crossEdges: Array<{ from: string; to: string; relation: string }> = []; + const edgeSet = new Set(); + + // 建立已有图谱的组件/接口名索引 + const existingIndex = new Map(); + for (const node of existing.nodes) { + existingIndex.set(node.label.toLowerCase(), node.id); + } + + // 建立新图谱的组件/接口名索引 + const overlayIndex = new Map(); + for (const node of overlay.nodes) { + overlayIndex.set(node.label.toLowerCase(), node.id); + } + + // 检查新仓库的 import 边目标是否有同名组件在已有仓库中 + for (const edge of overlay.edges) { + if (edge.relation !== 'imports') continue; + // 从 edge.to 文件路径提取可能的模块名 + const segments = edge.to.split('/'); + const fileName = segments[segments.length - 1]?.replace(/\.(ts|tsx|js|jsx|py|go|rs|java)$/, '') ?? ''; + // 将 kebab-case 转为 PascalCase 来匹配类名 + const pascalName = fileName.split(/[-_]/).map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(''); + + const match = existingIndex.get(pascalName.toLowerCase()); + if (match) { + const fromNode = overlay.nodes.find(n => n.file === edge.from); + if (fromNode) { + const key = `${fromNode.id}|${match}`; + if (!edgeSet.has(key)) { + edgeSet.add(key); + crossEdges.push({ from: fromNode.id, to: match, relation: 'DEPENDS_ON' }); + } + } + } + } + + // 反向:已有图谱的 import 边是否指向新仓库中的同名组件 + for (const edge of existing.edges) { + if (edge.relation !== 'imports') continue; + const segments = edge.to.split('/'); + const fileName = segments[segments.length - 1]?.replace(/\.(ts|tsx|js|jsx|py|go|rs|java)$/, '') ?? ''; + const pascalName = fileName.split(/[-_]/).map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(''); + + const match = overlayIndex.get(pascalName.toLowerCase()); + if (match) { + const fromNode = existing.nodes.find(n => n.file === edge.from); + if (fromNode) { + const key = `${fromNode.id}|${match}`; + if (!edgeSet.has(key)) { + edgeSet.add(key); + crossEdges.push({ from: fromNode.id, to: match, relation: 'DEPENDS_ON' }); + } + } + } + } + + return crossEdges; +} + // ─── Helpers ──────────────────────────────────────────── /** @@ -598,6 +680,78 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise } } + // 4b. 生成 teamwiki/ 知识图谱产物 + const teamwikiRoot = output + ? path.resolve(output, '..', 'teamwiki') + : path.join(process.cwd(), 'teamwiki'); + if (!dryRun) { + const cacheWiki = path.join(cacheDir, 'teamwiki'); + try { + await extractCodebase({ path: cacheDir, project: slug, json: false }); + // 将产物从 cacheDir/teamwiki/ 移动到目标 teamwikiRoot + if (await fs.pathExists(cacheWiki)) { + const evidenceSrc = path.join(cacheWiki, 'evidence', 'code', slug); + const evidenceDest = path.join(teamwikiRoot, 'evidence', 'code', slug); + await fs.ensureDir(evidenceDest); + await fs.copy(evidenceSrc, evidenceDest, { overwrite: true }); + // 合并 graph-index + const srcGraph = path.join(cacheWiki, '.indices', 'graph-index.json'); + const destGraph = path.join(teamwikiRoot, '.indices', 'graph-index.json'); + await fs.ensureDir(path.join(teamwikiRoot, '.indices')); + if (await fs.pathExists(destGraph)) { + const { mergeGraphs } = await import('./wiki-engine/adapters/index.js'); + const existing = JSON.parse(await fs.readFile(destGraph, 'utf8')); + const overlay = JSON.parse(await fs.readFile(srcGraph, 'utf8')); + const merged2 = mergeGraphs(existing, overlay); + // 跨仓关系检测:检查新仓库的 relation facts 是否引用了已有仓库的文件/包 + const crossRepoEdges = detectCrossRepoEdges(overlay, existing, slug); + if (crossRepoEdges.length > 0) { + (merged2 as { edges: Array<{ from: string; to: string; relation: string }> }).edges.push(...crossRepoEdges); + log.debug(`[wiki-engine] 检测到 ${crossRepoEdges.length} 条跨仓关系`); + } + await fs.writeFile(destGraph, JSON.stringify(merged2, null, 2), 'utf8'); + } else { + await fs.copy(srcGraph, destGraph); + } + await fs.remove(cacheWiki); + } + // 更新顶层 router.md 和 index.md(追加新项目,不覆盖) + const routerPath = path.join(teamwikiRoot, 'router.md'); + const indexPath = path.join(teamwikiRoot, 'index.md'); + const projectLink = `[[code/${slug}/index]]`; + if (await fs.pathExists(routerPath)) { + const router = await fs.readFile(routerPath, 'utf8'); + if (!router.includes(projectLink)) { + await fs.writeFile(routerPath, router.trimEnd() + `\n- ${projectLink} — ${slug} 代码知识\n`, 'utf8'); + } + } else { + await fs.writeFile(routerPath, `# Team Wiki Router\n\nRoute broad questions to the relevant domain entrypoint.\n\n- ${projectLink} — ${slug} 代码知识\n`, 'utf8'); + } + if (await fs.pathExists(indexPath)) { + const idx = await fs.readFile(indexPath, 'utf8'); + if (!idx.includes(`${slug}`)) { + const insertPoint = idx.indexOf('## Navigation'); + if (insertPoint > 0) { + const before = idx.slice(0, insertPoint); + const after = idx.slice(insertPoint); + await fs.writeFile(indexPath, before + `- [${slug}](./evidence/code/${slug}/index.md) — 代码知识图谱\n\n` + after, 'utf8'); + } + } + } else { + await fs.writeFile(indexPath, `# Team Wiki Index\n\nLast updated: ${new Date().toISOString()}\n\n## Domains\n\n- [${slug}](./evidence/code/${slug}/index.md) — 代码知识图谱\n\n## Navigation\n\n- [router.md](./router.md) — 领域路由入口\n- [hot.md](./hot.md) — 活跃工作记忆\n`, 'utf8'); + } + if (!await fs.pathExists(path.join(teamwikiRoot, 'hot.md'))) { + await fs.writeFile(path.join(teamwikiRoot, 'hot.md'), '# Hot Context\n\nKeep only active working memory here: current focus, recent decisions, open questions.\nMove durable conclusions into domain pages.\n', 'utf8'); + } + + log.info(chalk.green(`✓ teamwiki/ 知识图谱已更新: ${slug}`)); + } catch (err) { + log.debug(`[wiki-engine] 图谱生成失败(非阻塞): ${err instanceof Error ? err.message : err}`); + } finally { + await fs.remove(cacheWiki).catch(() => {}); + } + } + // 5. 业务域推荐 const cwd = process.cwd(); // 当无 --output 时,domains.yaml 写入团队仓库(共享),否则写入 cwd @@ -790,5 +944,21 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise await regenerateAggregate({ paths: aggPaths, domains: freshDomains }); log.info(`聚合文件已更新`); } catch { /* 非关键路径 */ } + + // 9. 自动推送所有产物到团队仓库 + try { + const { pushRepoDirectly } = await import('./utils/git.js'); + const teamRepoPath = path.join(domainsBase, '.teamai', 'team-repo'); + if (await fs.pathExists(teamRepoPath)) { + await pushRepoDirectly( + teamRepoPath, + `[teamai] Import codebase knowledge from ${owner}/${repoName}`, + ['.'], + ); + log.success('已推送到团队仓库'); + } + } catch (err) { + log.debug(`[push] 自动推送失败(可手动 teamai push): ${err instanceof Error ? err.message : err}`); + } } } diff --git a/src/import.ts b/src/import.ts index e137c17..69f18ad 100644 --- a/src/import.ts +++ b/src/import.ts @@ -13,6 +13,16 @@ import { importFromOrg } from './import-org.js'; import { importFromIWikiDual } from './iwiki-dual.js'; import { GlobalOptions } from './types.js'; import { log } from './utils/logger.js'; +import { pushRepoDirectly } from './utils/git.js'; + +async function autoPushTeamRepo(repoPath: string, message: string): Promise { + try { + await pushRepoDirectly(repoPath, message, ['.']); + log.success('已推送到团队仓库'); + } catch (err) { + log.debug(`[push] 自动推送失败(可手动 teamai push): ${err instanceof Error ? err.message : err}`); + } +} /** * import 命令的扩展选项,合并全局选项与子命令专属选项。 @@ -180,6 +190,9 @@ export async function importCmd(opts: ImportOptions): Promise { existingCodebaseMd, dryRun: opts.dryRun, }); + if (!opts.dryRun && !opts.output) { + await autoPushTeamRepo(localConfig.repo.localPath, `[teamai] Import from MR: ${opts.fromMr}`); + } } else if (opts.workspace) { // 分支 2:--workspace,从当前 git 工作区生成 codebase.md const repoPath = process.cwd(); @@ -248,12 +261,12 @@ export async function importCmd(opts: ImportOptions): Promise { }); log.success('导入完成'); if (pushed > 0 && !opts.dryRun && !opts.output) { - log.info('文件已写入本地团队仓库,运行 `teamai push` 推送到远程仓库'); + await autoPushTeamRepo(localConfig.repo.localPath, `[teamai] Import from local: ${opts.dir ?? 'claude-rules'}`); } } else { // 默认:未指定来源,提示用户 log.info('请指定导入来源:--dir 、--from-claude、--workspace、--from-mr 或 --from-iwiki '); - process.exit(0); + return; } } catch (err: unknown) { log.error((err as Error).message); diff --git a/src/index.ts b/src/index.ts index 2823e71..8fe9b8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -538,11 +538,12 @@ program program .command('recall [query...]') .description('Search team learnings knowledge base') - .action(async (queryParts) => { + .option('--depth ', 'Recall depth for codebase: route / context / lookup', 'context') + .action(async (queryParts, cmdOpts) => { const globalOpts = program.opts() as GlobalOptions; const query = (queryParts as string[]).join(' '); const { recall } = await import('./recall.js'); - await recall(query, globalOpts); + await recall(query, { ...globalOpts, depth: cmdOpts.depth }); }); program @@ -581,7 +582,6 @@ program .option('--output ', 'Write drafts to this directory instead of pushing to team repo') .option('--existing-codebase ', 'Path to existing codebase.md (used with --from-mr; overrides auto-detection from team repo)') .option('--from-repo ', 'Clone a remote repo and generate per-repo codebase summary') - .option('--depth ', 'Shallow clone depth for --from-repo (default 1)', '1') .option('--ssh', 'Force SSH clone even if HTTPS token is available') .option('--domain ', 'Skip AI recommendation and assign repo to this domain explicitly') .option('--from-repo-list ', 'Batch import repos from a YAML whitelist') @@ -618,6 +618,11 @@ program program .command('codebase') .description('Inspect and maintain team-codebase outputs') + .option('--extract [path]', 'Extract code knowledge and build graph from source') + .option('--incremental', 'Only re-extract changed files (requires prior manifest)') + .option('--project ', 'Project slug for extract output (default: directory name)') + .option('--max-files ', 'Max source files to scan (default: 200)') + .option('--upgrade-wiki', 'Migrate docs/team-codebase/ to teamwiki/ graph format') .option('--lint', 'Run global consistency lint over docs/team-codebase') .option('--fix', 'Apply low-risk mechanical fixes (only with --lint)') .option('--severity ', 'Minimum severity to report: high|medium|low|info', 'info') diff --git a/src/pull.ts b/src/pull.ts index 4763693..aed4677 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -557,6 +557,29 @@ async function pullForScope( } } + // Sync teamwiki/ directory (codebase knowledge graph) + const teamwikiRepoDir = path.join(localConfig.repo.localPath, 'teamwiki'); + if (await pathExists(teamwikiRepoDir)) { + const syncTarget = localConfig.projectRoot ?? process.cwd(); + const localTeamwikiDir = path.join(syncTarget, 'teamwiki'); + // 检查本地 graph-index 是否比远端更新(避免覆盖未推送的本地产物) + const localGraph = path.join(localTeamwikiDir, '.indices', 'graph-index.json'); + const remoteGraph = path.join(teamwikiRepoDir, '.indices', 'graph-index.json'); + let shouldSync = true; + if (await pathExists(localGraph) && await pathExists(remoteGraph)) { + const localStat = await fse.stat(localGraph); + const remoteStat = await fse.stat(remoteGraph); + if (localStat.mtimeMs > remoteStat.mtimeMs) { + log.warn(`[${scopeLabel}] 本地 teamwiki/ 比远端更新,跳过覆盖(请先 teamai push)`); + shouldSync = false; + } + } + if (shouldSync) { + await fse.copy(teamwikiRepoDir, localTeamwikiDir, { overwrite: true }); + log.debug(`[${scopeLabel}] Synced teamwiki/ knowledge graph`); + } + } + // Build the index when ANY of the four categories has content. const hasAnySource = effectiveLearningsDir || @@ -580,7 +603,7 @@ async function pullForScope( docsDir: await pathExists(docsRepoDir) ? docsRepoDir : undefined, rulesDir: await pathExists(rulesRepoDir) ? rulesRepoDir : undefined, skillsDir: await pathExists(skillsRepoDir) ? skillsRepoDir : undefined, - codebaseDir: effectiveCodebaseDir, + codebaseDir: undefined, // codebase now served by teamwiki/ graph engine votesDir: votesExist ? votesDir : undefined, indexPath, }); diff --git a/src/recall.ts b/src/recall.ts index 66e67e3..b0a2709 100644 --- a/src/recall.ts +++ b/src/recall.ts @@ -7,6 +7,8 @@ import { readFileSafe, writeFile, ensureDir, pathExists } from './utils/fs.js'; import { log } from './utils/logger.js'; import type { GlobalOptions, UserVotes, SearchIndex, LocalConfig } from './types.js'; import { getTeamaiHome } from './types.js'; +import { queryCodeKnowledge } from './code-knowledge-recall.js'; +import type { CodeKnowledgeResult } from './code-knowledge-recall.js'; /** Resolve votes dir dynamically (respects HOME changes in tests). */ function getVotesLocalDir(): string { @@ -221,7 +223,7 @@ async function loadOrBuildScopeIndex( */ export async function recall( query: string, - options: GlobalOptions, + options: GlobalOptions & { depth?: 'route' | 'context' | 'lookup' }, ): Promise { if (!query || !query.trim()) { log.error('Usage: teamai recall '); @@ -256,7 +258,8 @@ export async function recall( log.debug('recall: project scope not available'); } - if (scopeIndexes.length === 0) { + const hasWiki = await pathExists(path.join(process.cwd(), 'teamwiki')); + if (scopeIndexes.length === 0 && !hasWiki) { log.info('No learnings available. Run `teamai pull` first to sync team knowledge.'); return; } @@ -276,6 +279,33 @@ export async function recall( } } + // ── Codebase knowledge graph recall ────────────────────── + const wikiRoot = path.join(process.cwd(), 'teamwiki'); + try { + const codeResults = await queryCodeKnowledge(query, { wikiRoot, limit: 3, depth: options.depth }); + for (const cr of codeResults) { + allResults.push({ + entry: { + filename: cr.page, + title: cr.title, + author: '', + date: '', + tags: [], + tokens: [], + votes: 0, + type: 'docs' as const, + domain: 'technical' as const, + path: path.join(wikiRoot, cr.page), + }, + score: cr.score, + scope: 'project', + learningsBase: wikiRoot, + }); + } + } catch { + log.warn('recall: 代码图谱检索不可用,可运行 teamai codebase --lint 诊断'); + } + // Re-sort merged results by score descending, then date descending allResults.sort((a, b) => { if (b.score !== a.score) return b.score - a.score; diff --git a/src/utils/iwiki-client.ts b/src/utils/iwiki-client.ts index 813989d..bdcda25 100644 --- a/src/utils/iwiki-client.ts +++ b/src/utils/iwiki-client.ts @@ -110,7 +110,7 @@ export class IWikiClient { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.token}`, - 'Accept': 'application/json', + 'Accept': 'application/json, text/event-stream', 'Content-Length': Buffer.byteLength(payload), }, }; diff --git a/src/wiki-engine/adapters/index.ts b/src/wiki-engine/adapters/index.ts new file mode 100644 index 0000000..16d838c --- /dev/null +++ b/src/wiki-engine/adapters/index.ts @@ -0,0 +1,26 @@ +/** + * Team Wiki Engine — vendored from Team Wiki project by @lurkacai. + * Core concepts: code fact extraction, knowledge graph, evidence pages. + */ + +export { collectCode } from '../code-knowledge/code-collector.js'; +export type { CodeCollectedFile, CollectCodeOptions } from '../code-knowledge/code-collector.js'; + +export { extractCodeFacts } from '../code-knowledge/code-extractors.js'; +export type { CodeFact, CodeFactKind, CodeEvidenceType } from '../code-knowledge/code-extractors.js'; + +export { buildCodeGraph, buildCodeGraphIndex } from '../code-knowledge/code-graph.js'; +export type { CodeGraphIndex } from '../code-knowledge/code-graph.js'; + +export { detectCodeIncrementalChanges } from '../code-knowledge/code-incremental.js'; + +export { + mergeGraphs, + loadGraphIndex, + saveGraphIndex, + createGraphIndex, + findNeighbors, + findNeighborsNHop, + GRAPH_INDEX_SCHEMA_VERSION, +} from '../core/graph-index.schema.js'; +export type { GraphIndex, GraphNode, GraphEdge, RelationType } from '../core/graph-index.schema.js'; diff --git a/src/wiki-engine/code-knowledge/code-collector.ts b/src/wiki-engine/code-knowledge/code-collector.ts new file mode 100644 index 0000000..70f3bf2 --- /dev/null +++ b/src/wiki-engine/code-knowledge/code-collector.ts @@ -0,0 +1,213 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { readFile, readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { safeIgnore, toPosix } from "../core/wiki-protocol.js"; + +const execFileAsync = promisify(execFile); + +export interface CodeCollectedFile { + path: string; + relativePath: string; + language: string; + sha256: string; + content: string; + isKeyFile?: boolean; + repo?: string; +} + +export const KEY_FILE_PATTERNS: Record = { + go: [/main\.go$/, /cmd\/.*\.go$/, /handler.*\.go$/, /server\.go$/, /router\.go$/], + python: [/main\.py$/, /app\.py$/, /server\.py$/, /routes?\.py$/, /models?\.py$/], + java: [/Application\.java$/, /Controller\.java$/, /Service\.java$/], + typescript: [/index\.ts$/, /server\.ts$/, /app\.ts$/, /router\.ts$/], + rust: [/main\.rs$/, /lib\.rs$/, /mod\.rs$/] +}; + +export function isKeyFile(relativePath: string, language: string): boolean { + const patterns = KEY_FILE_PATTERNS[language]; + if (!patterns) return false; + return patterns.some((pattern) => pattern.test(relativePath)); +} + +export interface CodeCollectionManifest { + schemaVersion: "team-wiki.code-collection.v1"; + root: string; + commit?: string; + collectedAt: string; + files: Array>; +} + +export interface CollectCodeOptions { + root: string; + maxFiles?: number; + includeTests?: boolean; + changedFiles?: string[]; +} + +export async function collectCode(options: CollectCodeOptions): Promise<{ manifest: CodeCollectionManifest; files: CodeCollectedFile[] }> { + const root = path.resolve(options.root); + const filePaths: string[] = []; + await walk(root, filePaths, options.includeTests ?? false); + + let filtered = filePaths.sort(); + + // Filter to only changed files if specified + if (options.changedFiles && options.changedFiles.length > 0) { + const changedSet = new Set(options.changedFiles.map((f) => toPosix(f))); + filtered = filtered.filter((fp) => { + const relativePath = toPosix(path.relative(root, fp)); + return changedSet.has(relativePath); + }); + } + + const limited = filtered.slice(0, options.maxFiles ?? 200); + const files: CodeCollectedFile[] = []; + + for (const filePath of limited) { + const content = await readFile(filePath, "utf8"); + const relativePath = toPosix(path.relative(root, filePath)); + const language = languageFor(filePath); + files.push({ + path: filePath, + relativePath, + language, + sha256: createHash("sha256").update(content).digest("hex"), + content, + isKeyFile: isKeyFile(relativePath, language) + }); + } + + return { + manifest: { + schemaVersion: "team-wiki.code-collection.v1", + root, + commit: await gitCommit(root), + collectedAt: new Date().toISOString(), + files: files.map(({ content: _content, ...file }) => file) + }, + files + }; +} + +async function walk(directory: string, results: string[], includeTests: boolean): Promise { + if (safeIgnore(directory)) { + return; + } + for (const entry of await readdir(directory, { withFileTypes: true })) { + const fullPath = path.join(directory, entry.name); + if (safeIgnore(fullPath) || (!includeTests && isTestPath(fullPath))) { + continue; + } + if (entry.isDirectory()) { + await walk(fullPath, results, includeTests); + } else if (entry.isFile() && isCodeFile(fullPath) && (await stat(fullPath)).size < 256_000) { + results.push(fullPath); + } + } +} + +function isCodeFile(filePath: string): boolean { + return [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".rs", ".java", ".json", ".yaml", ".yml"].includes( + path.extname(filePath).toLowerCase() + ); +} + +function isTestPath(filePath: string): boolean { + return /(^|\/|\\)(test|tests|__tests__|fixtures)(\/|\\)|\.test\.|\.spec\./u.test(filePath); +} + +function languageFor(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + return ({ ".ts": "typescript", ".tsx": "typescript", ".js": "javascript", ".jsx": "javascript", ".py": "python", ".go": "go", ".rs": "rust", ".java": "java", ".json": "json", ".yaml": "yaml", ".yml": "yaml" } as Record)[ext] ?? "text"; +} + +async function gitCommit(root: string): Promise { + try { + const { stdout } = await execFileAsync("git", ["-C", root, "rev-parse", "HEAD"]); + return stdout.trim() || undefined; + } catch { + return undefined; + } +} + +// --- Multi-repo support --- + +export interface RepoEntry { + name: string; + path: string; + language?: string; // auto-detected if not provided +} + +export interface MultiRepoCollectOptions { + repos: RepoEntry[]; + maxFilesPerRepo?: number; + includeTests?: boolean; +} + +export interface MultiRepoManifest { + schemaVersion: "team-wiki.multi-repo.v1"; + repos: Array; + collectedAt: string; + totalFiles: number; +} + +export async function collectMultiRepo(options: MultiRepoCollectOptions): Promise<{ + manifest: MultiRepoManifest; + files: CodeCollectedFile[]; +}> { + const allFiles: CodeCollectedFile[] = []; + const repoDetails: MultiRepoManifest["repos"] = []; + + for (const repo of options.repos) { + const collection = await collectCode({ + root: repo.path, + maxFiles: options.maxFilesPerRepo ?? 200, + includeTests: options.includeTests ?? false + }); + + const repoFiles = collection.files.map((file) => ({ ...file, repo: repo.name })); + allFiles.push(...repoFiles); + + const primaryLanguage = repo.language ?? detectPrimaryLanguage(repoFiles); + repoDetails.push({ + name: repo.name, + path: repo.path, + language: repo.language, + commit: collection.manifest.commit, + fileCount: repoFiles.length, + primaryLanguage + }); + } + + return { + manifest: { + schemaVersion: "team-wiki.multi-repo.v1", + repos: repoDetails, + collectedAt: new Date().toISOString(), + totalFiles: allFiles.length + }, + files: allFiles + }; +} + +function detectPrimaryLanguage(files: CodeCollectedFile[]): string { + const counts = new Map(); + for (const file of files) { + if (file.language !== "json" && file.language !== "yaml" && file.language !== "text") { + counts.set(file.language, (counts.get(file.language) ?? 0) + 1); + } + } + if (counts.size === 0) return "unknown"; + let max = 0; + let primary = "unknown"; + for (const [lang, count] of counts) { + if (count > max) { + max = count; + primary = lang; + } + } + return primary; +} diff --git a/src/wiki-engine/code-knowledge/code-extractors.ts b/src/wiki-engine/code-knowledge/code-extractors.ts new file mode 100644 index 0000000..c37dd41 --- /dev/null +++ b/src/wiki-engine/code-knowledge/code-extractors.ts @@ -0,0 +1,73 @@ +import { type CodeCollectedFile } from "./code-collector.js"; +import { extractForLanguage } from "./extractors/index.js"; + +export type CodeFactKind = "component" | "interface" | "config" | "error" | "data" | "style" | "relation"; + +export type CodeEvidenceType = "definition" | "implementation" | "usage" | "schema" | "config"; + +/** + * Map a CodeFactKind to a WikiEvidenceType. + */ +export function mapKindToEvidenceType(kind: CodeFactKind): CodeEvidenceType { + switch (kind) { + case "component": + case "interface": + case "error": + return "definition"; + case "config": + return "config"; + case "data": + return "schema"; + case "relation": + return "usage"; + case "style": + return "definition"; + } +} + +export interface CodeFact { + kind: CodeFactKind; + name: string; + file: string; + lineStart: number; + lineEnd?: number; + detail: string; + confidence: "EXTRACTED" | "INFERRED" | "AMBIGUOUS"; + evidenceType?: CodeEvidenceType; +} + +/** + * Extract code facts from collected files. + * Groups files by language, then dispatches to language-specific extractors. + */ +export function extractCodeFacts(files: CodeCollectedFile[]): CodeFact[] { + const byLanguage = groupByLanguage(files); + const facts: CodeFact[] = []; + for (const [language, langFiles] of byLanguage) { + facts.push(...extractForLanguage(language, langFiles)); + } + return dedupe(facts); +} + +function groupByLanguage(files: CodeCollectedFile[]): Map { + const map = new Map(); + for (const file of files) { + const group = map.get(file.language) ?? []; + group.push(file); + map.set(file.language, group); + } + return map; +} + +function dedupe(facts: CodeFact[]): CodeFact[] { + const seen = new Set(); + const result: CodeFact[] = []; + for (const fact of facts) { + const key = `${fact.kind}:${fact.name}:${fact.file}:${fact.lineStart}`; + if (!seen.has(key)) { + seen.add(key); + result.push(fact); + } + } + return result; +} diff --git a/src/wiki-engine/code-knowledge/code-graph.ts b/src/wiki-engine/code-knowledge/code-graph.ts new file mode 100644 index 0000000..953905b --- /dev/null +++ b/src/wiki-engine/code-knowledge/code-graph.ts @@ -0,0 +1,171 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { type CodeFact } from "./code-extractors.js"; +import { + type GraphIndex, + type GraphNode, + type GraphEdge, + createGraphIndex, + addNode, + addEdge, + GRAPH_INDEX_SCHEMA_VERSION, +} from "../core/graph-index.schema.js"; + +export interface CodeGraphIndex { + schemaVersion: "team-wiki.code-graph.v1"; + generatedAt: string; + nodes: Array<{ id: string; kind: CodeFact["kind"]; label: string; file: string }>; + edges: Array<{ from: string; to: string; relation: "imports" | "mentions" }>; +} + +export async function writeCodeGraph(wikiRoot: string, project: string, facts: CodeFact[]): Promise<{ graph: CodeGraphIndex; path: string }> { + const graph = buildCodeGraph(facts); + const graphPath = path.join(wikiRoot, "graph", `${project}-graph-index.json`); + await mkdir(path.dirname(graphPath), { recursive: true }); + await writeFile(graphPath, `${JSON.stringify(graph, null, 2)}\n`, "utf8"); + return { graph, path: graphPath }; +} + +export function buildCodeGraph(facts: CodeFact[]): CodeGraphIndex { + const nodes = facts + .filter((fact) => fact.kind !== "relation") + .map((fact) => ({ id: `${fact.kind}:${fact.name}:${fact.file}`, kind: fact.kind, label: fact.name, file: fact.file })); + const nodeFiles = new Set(nodes.map((node) => node.file)); + const edges = facts + .filter((fact) => fact.kind === "relation") + .flatMap((fact) => [...nodeFiles].filter((file) => relationMayTarget(fact.name, file)).map((file) => ({ from: fact.file, to: file, relation: "imports" as const }))); + return { schemaVersion: "team-wiki.code-graph.v1", generatedAt: new Date().toISOString(), nodes, edges }; +} + +function relationMayTarget(importTarget: string, file: string): boolean { + const normalized = importTarget.replace(/^\.\//u, "").replace(/\.(ts|tsx|js|jsx)$/u, ""); + return file.includes(normalized); +} + +// ─── Unified Graph Compiler: build a full GraphIndex from component-level data ── + +export interface CodeComponent { + slug: string; + title: string; + category: string; + imports: string[]; + exports: string[]; + calls: string[]; +} + +/** + * Build a full GraphIndex from high-level code components. + * + * Creates DEPENDS_ON edges from imports (component A imports component B), + * and REFERENCES edges from call chains (component A calls into component B). + */ +export function buildCodeGraphIndex(components: Array<{ + slug: string; + title: string; + category: string; + imports: string[]; + exports: string[]; + calls: string[]; +}>): GraphIndex { + const nodes: GraphNode[] = components.map((c) => ({ + slug: c.slug, + type: mapCategoryToWikiCategory(c.category), + confidence: "EXTRACTED" as const, + title: c.title, + })); + + const edges: GraphEdge[] = []; + const edgeSet = new Set(); + + // Build a lookup: export name → component slug + const exportIndex = new Map(); + for (const comp of components) { + for (const exp of comp.exports) { + exportIndex.set(exp, comp.slug); + } + } + + // Build DEPENDS_ON edges from imports + for (const comp of components) { + for (const imp of comp.imports) { + const targetSlug = exportIndex.get(imp) ?? findComponentBySlugMatch(imp, components); + if (targetSlug && targetSlug !== comp.slug) { + const key = `${comp.slug}|${targetSlug}|DEPENDS_ON`; + if (!edgeSet.has(key)) { + edgeSet.add(key); + edges.push({ + from: comp.slug, + to: targetSlug, + relation: "DEPENDS_ON", + weight: 0.9, + }); + } + } + } + } + + // Build REFERENCES edges from call chains + for (const comp of components) { + for (const call of comp.calls) { + const targetSlug = exportIndex.get(call) ?? findComponentBySlugMatch(call, components); + if (targetSlug && targetSlug !== comp.slug) { + const key = `${comp.slug}|${targetSlug}|REFERENCES`; + if (!edgeSet.has(key)) { + edgeSet.add(key); + edges.push({ + from: comp.slug, + to: targetSlug, + relation: "REFERENCES", + weight: 0.7, + }); + } + } + } + } + + return createGraphIndex(nodes, edges); +} + +/** + * Try to match an import/call target to a component slug by substring matching. + */ +function findComponentBySlugMatch( + target: string, + components: Array<{ slug: string }> +): string | undefined { + const normalized = target.toLowerCase().replace(/[^a-z0-9]/g, ""); + return components.find((c) => { + const slugNorm = c.slug.toLowerCase().replace(/[^a-z0-9]/g, ""); + return slugNorm.includes(normalized) || normalized.includes(slugNorm); + })?.slug; +} + +/** + * Map a freeform category string to a WikiCategory type. + */ +function mapCategoryToWikiCategory(category: string): "component" | "interface" | "config" | "rule" | "process" | "decision" | "mapping" { + switch (category.toLowerCase()) { + case "component": + case "module": + case "service": + return "component"; + case "interface": + case "api": + case "type": + return "interface"; + case "config": + case "configuration": + return "config"; + case "rule": + case "validation": + return "rule"; + case "process": + case "workflow": + return "process"; + case "decision": + return "decision"; + default: + return "component"; + } +} diff --git a/src/wiki-engine/code-knowledge/code-incremental.ts b/src/wiki-engine/code-knowledge/code-incremental.ts new file mode 100644 index 0000000..d9147a9 --- /dev/null +++ b/src/wiki-engine/code-knowledge/code-incremental.ts @@ -0,0 +1,45 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +import { collectCode } from "./code-collector.js"; + +export interface CodeIncrementalChange { + added: string[]; + changed: string[]; + deleted: string[]; + affectedPages: string[]; +} + +export async function detectCodeIncrementalChanges(root: string, manifestPath: string, project: string): Promise { + const previous = (await exists(manifestPath)) ? (JSON.parse(await readFile(manifestPath, "utf8")) as { files?: Array<{ relativePath: string; sha256: string }> }) : { files: [] }; + const current = await collectCode({ root }); + const previousByPath = new Map((previous.files ?? []).map((file) => [file.relativePath, file.sha256])); + const currentByPath = new Map(current.manifest.files.map((file) => [file.relativePath, file.sha256])); + const added = [...currentByPath.keys()].filter((file) => !previousByPath.has(file)).sort(); + const changed = [...currentByPath.entries()].filter(([file, sha]) => previousByPath.has(file) && previousByPath.get(file) !== sha).map(([file]) => file).sort(); + const deleted = [...previousByPath.keys()].filter((file) => !currentByPath.has(file)).sort(); + return { added, changed, deleted, affectedPages: affectedPages(project, [...added, ...changed, ...deleted]) }; +} + +function affectedPages(project: string, files: string[]): string[] { + const pages = new Set([`code/${project}/index.md`]); + for (const file of files) { + if (/config|\.json$|\.ya?ml$/u.test(file)) { + pages.add(`code/${project}/config.md`); + } + if (/error|exception/i.test(file)) { + pages.add(`code/${project}/error.md`); + } + pages.add(`code/${project}/component.md`); + } + return [...pages].sort(); +} + +async function exists(filePath: string): Promise { + try { + await stat(path.resolve(filePath)); + return true; + } catch { + return false; + } +} diff --git a/src/wiki-engine/code-knowledge/extractors/go.ts b/src/wiki-engine/code-knowledge/extractors/go.ts new file mode 100644 index 0000000..24686ba --- /dev/null +++ b/src/wiki-engine/code-knowledge/extractors/go.ts @@ -0,0 +1,130 @@ +import { type CodeCollectedFile } from "../code-collector.js"; +import { type CodeFact, type CodeFactKind, mapKindToEvidenceType } from "../code-extractors.js"; + +/** + * Go extractor. + * Extracts structs, funcs, interfaces, HTTP handlers, configs, errors, and import relations. + */ +export function extractGo(files: CodeCollectedFile[]): CodeFact[] { + const facts: CodeFact[] = []; + + for (const file of files) { + const lines = file.content.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNumber = i + 1; + + // --- Components --- + const structDecl = /^type\s+([A-Z][A-Za-z0-9_]*)\s+struct\b/u.exec(line); + if (structDecl) { + facts.push(makeFact("component", structDecl[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const funcNew = /^func\s+New([A-Z][A-Za-z0-9_]*)\s*\(/u.exec(line); + if (funcNew) { + facts.push(makeFact("component", `New${funcNew[1]}`, file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const packageDecl = /^package\s+([a-z][a-z0-9_]*)/u.exec(line); + if (packageDecl) { + facts.push(makeFact("component", `package:${packageDecl[1]}`, file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const topLevelFunc = /^func\s+([A-Z][A-Za-z0-9_]*)\s*\(/u.exec(line); + if (topLevelFunc && !funcNew) { + facts.push(makeFact("component", topLevelFunc[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // --- Interfaces --- + const ifaceDecl = /^type\s+([A-Z][A-Za-z0-9_]*)\s+interface\b/u.exec(line); + if (ifaceDecl) { + facts.push(makeFact("interface", ifaceDecl[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // HTTP handler methods: func (h *Handler) ServeHTTP(...) + const handlerMethod = /^func\s+\([^)]*\*?(\w+)\)\s+(ServeHTTP|Handle|Handler)\s*\(/u.exec(line); + if (handlerMethod) { + facts.push(makeFact("interface", `${handlerMethod[1]}.${handlerMethod[2]}`, file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // Router registrations: r.HandleFunc("/path", handler) + const routeReg = /\.\s*(?:HandleFunc|Handle|Get|Post|Put|Delete|Patch)\s*\(\s*["'](\/[^"']*)/u.exec(line); + if (routeReg) { + facts.push(makeFact("interface", routeReg[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // --- Configs --- + const envGet = /os\.Getenv\(\s*["']([A-Z][A-Z0-9_]+)["']\s*\)/u.exec(line); + if (envGet) { + facts.push(makeFact("config", envGet[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // yaml/toml struct tags + const structTag = /`(?:yaml|toml|json):"([^",]+)"/u.exec(line); + if (structTag) { + facts.push(makeFact("config", `tag:${structTag[1]}`, file.relativePath, lineNumber, line, "INFERRED")); + } + + // --- Errors --- + const errVar = /^var\s+(Err[A-Z][A-Za-z0-9_]*)\s*=\s*(?:errors\.New|fmt\.Errorf)/u.exec(line); + if (errVar) { + facts.push(makeFact("error", errVar[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const errConst = /^\s*(Err[A-Z][A-Za-z0-9_]*)\s*(?:=|error)/u.exec(line); + if (errConst && !errVar) { + const inBlock = isInsideBlock(lines, i, "const", "var"); + if (inBlock) { + facts.push(makeFact("error", errConst[1], file.relativePath, lineNumber, line, "INFERRED")); + } + } + + const fmtErrorf = /fmt\.Errorf\s*\(\s*["']([^"']{1,60})/u.exec(line); + if (fmtErrorf && !errVar) { + facts.push(makeFact("error", fmtErrorf[1], file.relativePath, lineNumber, line, "INFERRED")); + } + + // --- Relations --- + const importPath = /^\s*"([^"]+)"/u.exec(line); + if (importPath && isInsideBlock(lines, i, "import")) { + facts.push(makeFact("relation", importPath[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const singleImport = /^import\s+"([^"]+)"/u.exec(line); + if (singleImport) { + facts.push(makeFact("relation", singleImport[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + } + } + + return facts; +} + +/** + * Checks if the current line index is inside a block starting with one of the given keywords. + */ +function isInsideBlock(lines: string[], currentIndex: number, ...keywords: string[]): boolean { + for (let j = currentIndex - 1; j >= Math.max(0, currentIndex - 50); j--) { + const candidate = lines[j]; + if (/^\s*\)\s*$/u.test(candidate)) { + return false; + } + for (const keyword of keywords) { + if (new RegExp(`^${keyword}\\s*\\(`, "u").test(candidate)) { + return true; + } + } + } + return false; +} + +function makeFact( + kind: CodeFactKind, + name: string, + file: string, + lineStart: number, + rawLine: string, + confidence: CodeFact["confidence"] +): CodeFact { + return { kind, name, file, lineStart, detail: rawLine.trim(), confidence, evidenceType: mapKindToEvidenceType(kind) }; +} diff --git a/src/wiki-engine/code-knowledge/extractors/index.ts b/src/wiki-engine/code-knowledge/extractors/index.ts new file mode 100644 index 0000000..fd1b890 --- /dev/null +++ b/src/wiki-engine/code-knowledge/extractors/index.ts @@ -0,0 +1,46 @@ +import { type CodeCollectedFile } from "../code-collector.js"; +import { type CodeFact } from "../code-extractors.js"; +import { extractGo } from "./go.js"; +import { extractJava } from "./java.js"; +import { extractPython } from "./python.js"; +import { extractRust } from "./rust.js"; +import { extractTypescript } from "./typescript.js"; + +type LanguageExtractor = (files: CodeCollectedFile[]) => CodeFact[]; + +/** + * Registry mapping language identifiers to their specialized extractors. + */ +const EXTRACTOR_REGISTRY: Record = { + typescript: extractTypescript, + javascript: extractTypescript, // JS uses the same TS extractor (compatible patterns) + go: extractGo, + python: extractPython, + java: extractJava, + rust: extractRust, +}; + +/** + * Dispatch extraction to the appropriate language-specific extractor. + * Falls back to an empty array for unsupported languages (json, yaml, text, etc.). + */ +export function extractForLanguage(language: string, files: CodeCollectedFile[]): CodeFact[] { + const extractor = EXTRACTOR_REGISTRY[language]; + if (!extractor) { + return []; + } + return extractor(files); +} + +/** + * Returns the list of languages with registered extractors. + */ +export function supportedLanguages(): string[] { + return Object.keys(EXTRACTOR_REGISTRY); +} + +export { extractGo } from "./go.js"; +export { extractJava } from "./java.js"; +export { extractPython } from "./python.js"; +export { extractRust } from "./rust.js"; +export { extractTypescript } from "./typescript.js"; diff --git a/src/wiki-engine/code-knowledge/extractors/java.ts b/src/wiki-engine/code-knowledge/extractors/java.ts new file mode 100644 index 0000000..19f0629 --- /dev/null +++ b/src/wiki-engine/code-knowledge/extractors/java.ts @@ -0,0 +1,126 @@ +import { type CodeCollectedFile } from "../code-collector.js"; +import { type CodeFact, type CodeFactKind, mapKindToEvidenceType } from "../code-extractors.js"; + +/** + * Java extractor. + * Extracts classes, Spring annotations, interfaces, controllers, configs, errors, and imports. + */ +export function extractJava(files: CodeCollectedFile[]): CodeFact[] { + const facts: CodeFact[] = []; + + for (const file of files) { + const lines = file.content.split(/\r?\n/); + let pendingAnnotations: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNumber = i + 1; + + // Collect annotations for context on the next declaration + const annotation = /^\s*@([A-Za-z]+)/u.exec(line); + if (annotation) { + pendingAnnotations.push(annotation[1]); + } + + // --- Components --- + const classDecl = /^(?:public|protected|private)?\s*(?:abstract\s+)?(?:final\s+)?class\s+([A-Z][A-Za-z0-9_]*)/u.exec(line); + if (classDecl) { + const isSpringComponent = pendingAnnotations.some((a) => + ["Component", "Service", "Repository", "Configuration", "Bean"].includes(a) + ); + facts.push(makeFact("component", classDecl[1], file.relativePath, lineNumber, line, "EXTRACTED")); + + if (isSpringComponent) { + const springType = pendingAnnotations.find((a) => + ["Component", "Service", "Repository", "Configuration"].includes(a) + ); + if (springType) { + facts.push(makeFact("component", `@${springType}:${classDecl[1]}`, file.relativePath, lineNumber, line, "EXTRACTED")); + } + } + } + + // Enum declaration + const enumDecl = /^(?:public|protected|private)?\s*enum\s+([A-Z][A-Za-z0-9_]*)/u.exec(line); + if (enumDecl) { + facts.push(makeFact("component", enumDecl[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // --- Interfaces --- + const ifaceDecl = /^(?:public|protected|private)?\s*interface\s+([A-Z][A-Za-z0-9_]*)/u.exec(line); + if (ifaceDecl) { + facts.push(makeFact("interface", ifaceDecl[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // Controllers and REST endpoints + const isController = pendingAnnotations.some((a) => + ["Controller", "RestController"].includes(a) + ); + if (isController && classDecl) { + facts.push(makeFact("interface", `@Controller:${classDecl[1]}`, file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // RequestMapping and method mappings + const requestMapping = /@(?:RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping)\s*\(\s*(?:value\s*=\s*)?["'](\/[^"']*)/u.exec(line); + if (requestMapping) { + facts.push(makeFact("interface", requestMapping[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // --- Configs --- + const valueAnnotation = /@Value\s*\(\s*["']\$\{([^}]+)\}/u.exec(line); + if (valueAnnotation) { + facts.push(makeFact("config", valueAnnotation[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // application.properties/yml style references + const propRef = /["']([a-z][a-z0-9._-]{3,})["']/u.exec(line); + if (propRef && isConfigFile(file.relativePath)) { + facts.push(makeFact("config", propRef[1], file.relativePath, lineNumber, line, "INFERRED")); + } + + // --- Errors --- + const errorEnum = /^(?:public|protected|private)?\s*enum\s+([A-Z][A-Za-z0-9_]*(?:Error|Code|Status))\b/u.exec(line); + if (errorEnum) { + facts.push(makeFact("error", errorEnum[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const throwStmt = /throw\s+new\s+([A-Za-z_$][\w$]*Exception)\s*\(/u.exec(line); + if (throwStmt) { + facts.push(makeFact("error", throwStmt[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const exceptionClass = /^(?:public|protected|private)?\s*class\s+([A-Z][A-Za-z0-9_]*Exception)\b/u.exec(line); + if (exceptionClass) { + facts.push(makeFact("error", exceptionClass[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // --- Relations --- + const importStmt = /^import\s+(?:static\s+)?([a-z][\w.]*\.[A-Z][\w]*)/u.exec(line); + if (importStmt) { + facts.push(makeFact("relation", importStmt[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // Reset annotations if we hit a non-annotation, non-blank line + if (!annotation && line.trim().length > 0) { + pendingAnnotations = []; + } + } + } + + return facts; +} + +function isConfigFile(relativePath: string): boolean { + return /(?:application|bootstrap|config)\.(?:properties|ya?ml)$/iu.test(relativePath); +} + +function makeFact( + kind: CodeFactKind, + name: string, + file: string, + lineStart: number, + rawLine: string, + confidence: CodeFact["confidence"] +): CodeFact { + return { kind, name, file, lineStart, detail: rawLine.trim(), confidence, evidenceType: mapKindToEvidenceType(kind) }; +} diff --git a/src/wiki-engine/code-knowledge/extractors/python.ts b/src/wiki-engine/code-knowledge/extractors/python.ts new file mode 100644 index 0000000..3397372 --- /dev/null +++ b/src/wiki-engine/code-knowledge/extractors/python.ts @@ -0,0 +1,126 @@ +import { type CodeCollectedFile } from "../code-collector.js"; +import { type CodeFact, type CodeFactKind, mapKindToEvidenceType } from "../code-extractors.js"; + +/** + * Python extractor. + * Extracts classes, module-level functions, ABC interfaces, route decorators, + * configs, errors, and import relations. + */ +export function extractPython(files: CodeCollectedFile[]): CodeFact[] { + const facts: CodeFact[] = []; + + for (const file of files) { + const lines = file.content.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNumber = i + 1; + + // --- Components --- + const classDecl = /^class\s+([A-Z][A-Za-z0-9_]*)\s*[:(]/u.exec(line); + if (classDecl && !isABCClass(line) && !isExceptionClass(line)) { + facts.push(makeFact("component", classDecl[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // Module-level function (not indented) + const funcDecl = /^(?:async\s+)?def\s+([a-z_][a-z0-9_]*)\s*\(/u.exec(line); + if (funcDecl) { + facts.push(makeFact("component", funcDecl[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // --- Interfaces --- + if (isABCClass(line)) { + const abcClass = /^class\s+([A-Z][A-Za-z0-9_]*)/u.exec(line); + if (abcClass) { + facts.push(makeFact("interface", abcClass[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + } + + // Flask/FastAPI route decorators + const flaskRoute = /@app\.route\s*\(\s*["'](\/[^"']*)/u.exec(line); + if (flaskRoute) { + facts.push(makeFact("interface", flaskRoute[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const fastapiRoute = /@(?:router|app)\.\s*(get|post|put|patch|delete)\s*\(\s*["'](\/[^"']*)/u.exec(line); + if (fastapiRoute) { + facts.push(makeFact("interface", `${fastapiRoute[1].toUpperCase()} ${fastapiRoute[2]}`, file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // Protocol class (typing) + const protocolClass = /^class\s+([A-Z][A-Za-z0-9_]*)\s*\(.*Protocol.*\)/u.exec(line); + if (protocolClass) { + facts.push(makeFact("interface", protocolClass[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // --- Configs --- + const osEnviron = /os\.environ\s*(?:\[["']|\.get\s*\(\s*["'])([A-Z][A-Z0-9_]+)/u.exec(line); + if (osEnviron) { + facts.push(makeFact("config", osEnviron[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const dotenvRead = /(?:config|settings|environ)\s*(?:\[["']|\.get\s*\(\s*["']|\.)\s*([A-Z][A-Z0-9_]{2,})/u.exec(line); + if (dotenvRead && !osEnviron) { + facts.push(makeFact("config", dotenvRead[1], file.relativePath, lineNumber, line, "INFERRED")); + } + + // Settings patterns (e.g., SETTING_NAME = ...) + const settingsPattern = /^([A-Z][A-Z0-9_]{3,})\s*[:=]\s*.+/u.exec(line); + if (settingsPattern && isSettingsFile(file.relativePath)) { + facts.push(makeFact("config", settingsPattern[1], file.relativePath, lineNumber, line, "INFERRED")); + } + + // --- Errors --- + if (isExceptionClass(line)) { + const errClass = /^class\s+([A-Z][A-Za-z0-9_]*)/u.exec(line); + if (errClass) { + facts.push(makeFact("error", errClass[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + } + + const raiseStmt = /raise\s+([A-Z][A-Za-z0-9_]*(?:Error|Exception)?)\s*\(/u.exec(line); + if (raiseStmt) { + facts.push(makeFact("error", raiseStmt[1], file.relativePath, lineNumber, line, "INFERRED")); + } + + // --- Relations --- + const fromImport = /^from\s+([\w.]+)\s+import\s+(.+)/u.exec(line); + if (fromImport) { + const modulePath = fromImport[1]; + const names = fromImport[2].split(",").map((n) => n.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean); + for (const name of names) { + facts.push(makeFact("relation", `${modulePath}.${name}`, file.relativePath, lineNumber, line, "EXTRACTED")); + } + } + + const importModule = /^import\s+([\w.]+)/u.exec(line); + if (importModule && !fromImport) { + facts.push(makeFact("relation", importModule[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + } + } + + return facts; +} + +function isABCClass(line: string): boolean { + return /^class\s+\w+\s*\(.*(?:ABC|ABCMeta|metaclass\s*=\s*ABCMeta).*\)/u.test(line); +} + +function isExceptionClass(line: string): boolean { + return /^class\s+\w+\s*\(.*(?:Exception|Error|BaseException).*\)/u.test(line); +} + +function isSettingsFile(relativePath: string): boolean { + return /(?:settings|config|constants|env)\.py$/iu.test(relativePath); +} + +function makeFact( + kind: CodeFactKind, + name: string, + file: string, + lineStart: number, + rawLine: string, + confidence: CodeFact["confidence"] +): CodeFact { + return { kind, name, file, lineStart, detail: rawLine.trim(), confidence, evidenceType: mapKindToEvidenceType(kind) }; +} diff --git a/src/wiki-engine/code-knowledge/extractors/rust.ts b/src/wiki-engine/code-knowledge/extractors/rust.ts new file mode 100644 index 0000000..7a71118 --- /dev/null +++ b/src/wiki-engine/code-knowledge/extractors/rust.ts @@ -0,0 +1,143 @@ +import { type CodeCollectedFile } from "../code-collector.js"; +import { type CodeFact, type CodeFactKind, mapKindToEvidenceType } from "../code-extractors.js"; + +/** + * Rust extractor. + * Extracts structs, impls, modules, traits, HTTP handlers, configs, errors, and use relations. + */ +export function extractRust(files: CodeCollectedFile[]): CodeFact[] { + const facts: CodeFact[] = []; + + for (const file of files) { + const lines = file.content.split(/\r?\n/); + let pendingAttributes: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNumber = i + 1; + + // Collect attributes for context + const attrMatch = /^\s*#\[([^\]]+)\]/u.exec(line); + if (attrMatch) { + pendingAttributes.push(attrMatch[1]); + // Don't continue — attribute line might also contain other patterns + } + + // --- Components --- + const pubStruct = /^pub(?:\(crate\))?\s+struct\s+([A-Z][A-Za-z0-9_]*)/u.exec(line); + if (pubStruct) { + facts.push(makeFact("component", pubStruct[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const implBlock = /^impl(?:<[^>]*>)?\s+([A-Z][A-Za-z0-9_]*)/u.exec(line); + if (implBlock && !/\bfor\b/u.test(line)) { + facts.push(makeFact("component", `impl:${implBlock[1]}`, file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const modDecl = /^pub(?:\(crate\))?\s+mod\s+([a-z][a-z0-9_]*)/u.exec(line); + if (modDecl) { + facts.push(makeFact("component", `mod:${modDecl[1]}`, file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const privateMod = /^mod\s+([a-z][a-z0-9_]*)\s*;/u.exec(line); + if (privateMod) { + facts.push(makeFact("component", `mod:${privateMod[1]}`, file.relativePath, lineNumber, line, "INFERRED")); + } + + const pubFn = /^pub(?:\(crate\))?\s+(?:async\s+)?fn\s+([a-z_][a-z0-9_]*)/u.exec(line); + if (pubFn) { + facts.push(makeFact("component", pubFn[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // --- Interfaces --- + const traitDecl = /^pub(?:\(crate\))?\s+trait\s+([A-Z][A-Za-z0-9_]*)/u.exec(line); + if (traitDecl) { + facts.push(makeFact("interface", traitDecl[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // Trait impl (impl Trait for Type) + const traitImpl = /^impl(?:<[^>]*>)?\s+([A-Z][A-Za-z0-9_]*)\s+for\s+([A-Z][A-Za-z0-9_]*)/u.exec(line); + if (traitImpl) { + facts.push(makeFact("interface", `${traitImpl[2]}:impl:${traitImpl[1]}`, file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // Actix/Axum HTTP handlers: #[get("/")] async fn handler + const httpAttr = pendingAttributes.find((a) => /^(?:get|post|put|patch|delete)\s*\(/iu.test(a)); + if (httpAttr && pubFn) { + const routePath = /\(\s*["'](\/[^"']*)/u.exec(httpAttr); + if (routePath) { + facts.push(makeFact("interface", `${httpAttr.split("(")[0].toUpperCase()} ${routePath[1]}`, file.relativePath, lineNumber, line, "EXTRACTED")); + } + } + + // Router registrations: .route("/path", get(handler)) + const routeReg = /\.route\s*\(\s*["'](\/[^"']*)/u.exec(line); + if (routeReg) { + facts.push(makeFact("interface", routeReg[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // --- Configs --- + const stdEnvVar = /std::env::var\s*\(\s*["']([A-Z][A-Z0-9_]+)["']\s*\)/u.exec(line); + if (stdEnvVar) { + facts.push(makeFact("config", stdEnvVar[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const envVar = /env::var\s*\(\s*["']([A-Z][A-Z0-9_]+)["']\s*\)/u.exec(line); + if (envVar && !stdEnvVar) { + facts.push(makeFact("config", envVar[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // Config structs in config.rs files + if (isConfigFile(file.relativePath) && pubStruct) { + facts.push(makeFact("config", `config:${pubStruct[1]}`, file.relativePath, lineNumber, line, "INFERRED")); + } + + // --- Errors --- + const thiserror = pendingAttributes.some((a) => /derive\(.*thiserror::Error/u.test(a) || /derive\(.*Error/u.test(a)); + const errorEnum = /^pub(?:\(crate\))?\s+enum\s+([A-Z][A-Za-z0-9_]*(?:Error)?)/u.exec(line); + if (errorEnum && thiserror) { + facts.push(makeFact("error", errorEnum[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } else if (errorEnum && /Error$/u.test(errorEnum[1])) { + facts.push(makeFact("error", errorEnum[1], file.relativePath, lineNumber, line, "INFERRED")); + } + + const errorStruct = /^pub(?:\(crate\))?\s+struct\s+([A-Z][A-Za-z0-9_]*Error)\b/u.exec(line); + if (errorStruct) { + facts.push(makeFact("error", errorStruct[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // --- Relations --- + const useDecl = /^use\s+([a-z_][\w:]*(?:::\{[^}]+\}|::\*|::[A-Z]\w*))/u.exec(line); + if (useDecl) { + facts.push(makeFact("relation", useDecl[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const externCrate = /^extern\s+crate\s+([a-z_][a-z0-9_]*)/u.exec(line); + if (externCrate) { + facts.push(makeFact("relation", externCrate[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // Reset attributes on non-attribute, non-blank lines + if (!attrMatch && line.trim().length > 0) { + pendingAttributes = []; + } + } + } + + return facts; +} + +function isConfigFile(relativePath: string): boolean { + return /(?:config|settings)\.rs$/iu.test(relativePath); +} + +function makeFact( + kind: CodeFactKind, + name: string, + file: string, + lineStart: number, + rawLine: string, + confidence: CodeFact["confidence"] +): CodeFact { + return { kind, name, file, lineStart, detail: rawLine.trim(), confidence, evidenceType: mapKindToEvidenceType(kind) }; +} diff --git a/src/wiki-engine/code-knowledge/extractors/typescript.ts b/src/wiki-engine/code-knowledge/extractors/typescript.ts new file mode 100644 index 0000000..7c3c566 --- /dev/null +++ b/src/wiki-engine/code-knowledge/extractors/typescript.ts @@ -0,0 +1,102 @@ +import { type CodeCollectedFile } from "../code-collector.js"; +import { type CodeFact, type CodeFactKind, mapKindToEvidenceType } from "../code-extractors.js"; + +/** + * Enhanced TypeScript/JavaScript extractor. + * Extracts components, interfaces/types, configs, errors, and relations. + */ +export function extractTypescript(files: CodeCollectedFile[]): CodeFact[] { + const facts: CodeFact[] = []; + + for (const file of files) { + const lines = file.content.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNumber = i + 1; + + // --- Components --- + const exportClass = /^export\s+(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/u.exec(line); + if (exportClass) { + facts.push(makeFact("component", exportClass[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const exportFunction = /^export\s+(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/u.exec(line); + if (exportFunction) { + facts.push(makeFact("component", exportFunction[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const exportConst = /^export\s+const\s+([A-Za-z_$][\w$]*)\s*=/u.exec(line); + if (exportConst && !/CONFIG|DEFAULT|OPTION|SETTING|ENV/u.test(exportConst[1])) { + facts.push(makeFact("component", exportConst[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const exportDefault = /^export\s+default\s+(?!class|function|abstract)([A-Za-z_$][\w$]*)/u.exec(line); + if (exportDefault) { + facts.push(makeFact("component", exportDefault[1], file.relativePath, lineNumber, line, "INFERRED")); + } + + // --- Interfaces / Types --- + const iface = /^export\s+(?:declare\s+)?interface\s+([A-Za-z_$][\w$]*)/u.exec(line); + if (iface) { + facts.push(makeFact("interface", iface[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const typeAlias = /^export\s+(?:declare\s+)?type\s+([A-Za-z_$][\w$]*)\s*[=<]/u.exec(line); + if (typeAlias) { + facts.push(makeFact("interface", typeAlias[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // Route definitions + const route = /(?:router|app|server)\.\s*(get|post|put|patch|delete|all|use)\s*\(\s*["'`](\/[^"'`]*)/iu.exec(line); + if (route) { + facts.push(makeFact("interface", `${route[1].toUpperCase()} ${route[2]}`, file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // --- Configs --- + const envVar = /process\.env\.([A-Z][A-Z0-9_]{2,})/u.exec(line); + if (envVar) { + facts.push(makeFact("config", `process.env.${envVar[1]}`, file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const configConst = /^export\s+const\s+([A-Z][A-Z0-9_]*(?:CONFIG|DEFAULT|OPTION|SETTING|ENV)[A-Z0-9_]*)\s*=/u.exec(line); + if (configConst) { + facts.push(makeFact("config", configConst[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + // --- Errors --- + const throwNew = /throw\s+new\s+([A-Za-z_$][\w$]*Error)\b/u.exec(line); + if (throwNew) { + facts.push(makeFact("error", throwNew[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const errorConst = /\b([A-Z][A-Z0-9_]*(?:ERROR|ERR|FAILED|FAILURE)[A-Z0-9_]*)\b/u.exec(line); + if (errorConst && !throwNew) { + facts.push(makeFact("error", errorConst[1], file.relativePath, lineNumber, line, "INFERRED")); + } + + // --- Relations --- + const importFrom = /^import\s+.*?from\s+["']([^"']+)["']/u.exec(line); + if (importFrom) { + facts.push(makeFact("relation", importFrom[1], file.relativePath, lineNumber, line, "EXTRACTED")); + } + + const dynamicImport = /(?:await\s+)?import\s*\(\s*["']([^"']+)["']\s*\)/u.exec(line); + if (dynamicImport && !importFrom) { + facts.push(makeFact("relation", dynamicImport[1], file.relativePath, lineNumber, line, "INFERRED")); + } + } + } + + return facts; +} + +function makeFact( + kind: CodeFactKind, + name: string, + file: string, + lineStart: number, + rawLine: string, + confidence: CodeFact["confidence"] +): CodeFact { + return { kind, name, file, lineStart, detail: rawLine.trim(), confidence, evidenceType: mapKindToEvidenceType(kind) }; +} diff --git a/src/wiki-engine/code-knowledge/manifest-schema.ts b/src/wiki-engine/code-knowledge/manifest-schema.ts new file mode 100644 index 0000000..ac0f3b9 --- /dev/null +++ b/src/wiki-engine/code-knowledge/manifest-schema.ts @@ -0,0 +1,90 @@ +/** + * Codebase output manifest schema definitions. + * + * The manifest is the contract between AI compilers (e.g. team-wiki-codebase + * Skill) and the deterministic Node-side compiler (`compileFromManifest`). + * + * Two versions are supported: + * + * - **v1** — Original schema. Components carry slug/category/upstream/downstream + * and basic evidenceRefs. Edges only carry from/to/relation/confidence. + * + * - **v2** — Backward-compatible extension. All v1 fields preserved. + * Adds: + * - `component.entrypoints` / `component.responsibilities` — surfaced in + * the rendered component page as standard sections. + * - `edge.evidenceRefs` / `edge.reason` / `edge.sourceRange` — translated + * into `GraphEdge.evidence: WikiEvidence[]` so the graph "knows why two + * components are connected". + * + * The compiler dispatches on `schemaVersion` via `isManifestV2`. v1 manifests + * continue to compile with zero behaviour change. + */ + +export type ManifestConfidence = "EXTRACTED" | "INFERRED" | "AMBIGUOUS"; + +/** Optional provenance for manifest edges (GRAPH-CAPABILITIES). */ +export type ManifestEdgeSource = + | "code-ast" + | "code-heuristic" + | "doc-structure" + | "doc-entity" + | "agent"; + +interface ManifestComponentBase { + slug: string; + docPath: string; + title?: string; + category: string; + confidence: ManifestConfidence; + upstream?: string[]; + downstream?: string[]; + interfaces?: string[]; + errorCodeRanges?: string[]; + evidenceRefs?: string[]; +} + +interface ManifestEdgeBase { + from: string; + to: string; + relation: string; + protocol?: string; + confidence: ManifestConfidence; + weight?: number; +} + +export interface CodebaseOutputManifestV1 { + schemaVersion: "team-wiki.codebase-output-manifest.v1"; + project: string; + generatedAt: string; + components: ManifestComponentBase[]; + edges: ManifestEdgeBase[]; + graphLayers?: Record; +} + +export interface ManifestComponentV2 extends ManifestComponentBase { + entrypoints?: string[]; + responsibilities?: string[]; +} + +export interface ManifestEdgeV2 extends ManifestEdgeBase { + evidenceRefs?: string[]; + reason?: string; + source?: ManifestEdgeSource; + sourceRange?: { file: string; lines: [number, number] }; +} + +export interface CodebaseOutputManifestV2 { + schemaVersion: "team-wiki.codebase-output-manifest.v2"; + project: string; + generatedAt: string; + components: ManifestComponentV2[]; + edges: ManifestEdgeV2[]; + graphLayers?: Record; +} + +export type CodebaseOutputManifest = CodebaseOutputManifestV1 | CodebaseOutputManifestV2; + +export function isManifestV2(manifest: CodebaseOutputManifest): manifest is CodebaseOutputManifestV2 { + return manifest.schemaVersion === "team-wiki.codebase-output-manifest.v2"; +} diff --git a/src/wiki-engine/core/graph-index.schema.ts b/src/wiki-engine/core/graph-index.schema.ts new file mode 100644 index 0000000..b22fb7d --- /dev/null +++ b/src/wiki-engine/core/graph-index.schema.ts @@ -0,0 +1,417 @@ +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import path from "node:path"; + +import { CONFIDENCE_SCORE_DEFAULTS, type WikiCategory, type WikiConfidence, type WikiEvidence } from "./wiki-protocol.js"; + +/** + * Graph Index Schema — team-wiki.graph-index.v1 + * + * Formal schema for knowledge graph indices that capture + * relationships between wiki pages and code entities. + */ + +export const GRAPH_INDEX_SCHEMA_VERSION = "team-wiki.graph-index.v1" as const; + +export type RelationType = + | "DEPENDS_ON" + | "IMPLEMENTS" + | "MAPS_TO" + | "CONTAINS" + | "REFERENCES" + | "CONFLICTS_WITH" + | "SUPERSEDES"; + +export const RELATION_TYPES: RelationType[] = [ + "DEPENDS_ON", + "IMPLEMENTS", + "MAPS_TO", + "CONTAINS", + "REFERENCES", + "CONFLICTS_WITH", + "SUPERSEDES" +]; + +export interface GraphNode { + slug: string; + type: WikiCategory; + confidence: WikiConfidence; + title: string; + domain?: string; +} + +/** Provenance of a graph edge (compile / reconcile pipeline). */ +export type GraphEdgeSource = + | "code-ast" + | "code-heuristic" + | "doc-structure" + | "doc-entity" + | "doc-triples" + | "bridge-reconcile" + | "doc-semantic" + | "manual-mapping"; + +export interface GraphEdge { + from: string; + to: string; + relation: RelationType; + evidence?: WikiEvidence[]; + weight?: number; + /** Fine-grained semantic predicate (e.g. G6 CALLS_HTTP, USES_TABLE). */ + predicate?: string; + source?: GraphEdgeSource; +} + +/** Wiki page slug: relative path without `.md`. */ +export function toPageSlug(relativePath: string): string { + return relativePath.replace(/\.md$/u, "").replace(/\\/g, "/"); +} + +export interface GraphIndex { + schemaVersion: typeof GRAPH_INDEX_SCHEMA_VERSION; + generatedAt: string; + nodes: GraphNode[]; + edges: GraphEdge[]; +} + +/** + * Create an empty GraphIndex with the current timestamp. + */ +export function createGraphIndex(nodes: GraphNode[] = [], edges: GraphEdge[] = []): GraphIndex { + return { + schemaVersion: GRAPH_INDEX_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + nodes, + edges, + }; +} + +/** + * Add a node to the graph index. If a node with the same slug already exists, + * it is replaced with the new node. + */ +export function addNode(graph: GraphIndex, node: GraphNode): GraphIndex { + const filtered = graph.nodes.filter((n) => n.slug !== node.slug); + return { ...graph, nodes: [...filtered, node] }; +} + +/** + * Add an edge to the graph index. Duplicate edges (same from, to, relation) are not added. + */ +export function addEdge(graph: GraphIndex, edge: GraphEdge): GraphIndex { + const exists = graph.edges.some( + (e) => e.from === edge.from && e.to === edge.to && e.relation === edge.relation + ); + if (exists) { + return graph; + } + return { ...graph, edges: [...graph.edges, edge] }; +} + +/** + * Add an edge using confidence level as weight when no explicit weight is provided. + * Falls back to CONFIDENCE_SCORE_DEFAULTS for the given confidence level. + */ +export function addEdgeWithConfidence( + graph: GraphIndex, + edge: Omit & { weight?: number }, + confidence: WikiConfidence +): GraphIndex { + const weight = edge.weight ?? CONFIDENCE_SCORE_DEFAULTS[confidence]; + return addEdge(graph, { ...edge, weight }); +} + +/** + * Find all neighbor slugs of a given node (connected via any edge direction). + */ +export function findNeighbors(graph: GraphIndex, slug: string): string[] { + const neighbors = new Set(); + for (const edge of graph.edges) { + if (edge.from === slug) { + neighbors.add(edge.to); + } + if (edge.to === slug) { + neighbors.add(edge.from); + } + } + return [...neighbors].sort(); +} + +/** + * Find all neighbor slugs reachable within N hops. + * Optionally filter by specific relation types. + * Uses BFS to expand outward from the starting node. + */ +export function findNeighborsNHop( + graph: GraphIndex, + slug: string, + hops: number, + filterRelations?: RelationType[] +): string[] { + const visited = new Set([slug]); + let frontier = new Set([slug]); + + for (let hop = 0; hop < hops; hop++) { + const nextFrontier = new Set(); + for (const current of frontier) { + for (const edge of graph.edges) { + if (filterRelations && !filterRelations.includes(edge.relation)) { + continue; + } + let neighbor: string | null = null; + if (edge.from === current && !visited.has(edge.to)) { + neighbor = edge.to; + } else if (edge.to === current && !visited.has(edge.from)) { + neighbor = edge.from; + } + if (neighbor) { + visited.add(neighbor); + nextFrontier.add(neighbor); + } + } + } + frontier = nextFrontier; + if (frontier.size === 0) break; + } + + visited.delete(slug); // Remove starting node from results + return [...visited].sort(); +} + +export interface GraphValidationIssue { + code: "node.duplicate" | "edge.missing_node" | "edge.self_loop" | "edge.invalid_weight"; + message: string; +} + +export interface GraphValidationResult { + valid: boolean; + issues: GraphValidationIssue[]; +} + +/** + * Validate a graph index for structural correctness: + * - No duplicate node slugs + * - All edge endpoints reference existing nodes + * - No self-loop edges + * - Edge weights (if provided) are between 0 and 1 + */ +export function validateGraph(graph: GraphIndex): GraphValidationResult { + const issues: GraphValidationIssue[] = []; + const slugs = new Set(); + + for (const node of graph.nodes) { + if (slugs.has(node.slug)) { + issues.push({ + code: "node.duplicate", + message: `Duplicate node slug: ${node.slug}`, + }); + } + slugs.add(node.slug); + } + + for (const edge of graph.edges) { + if (!slugs.has(edge.from)) { + issues.push({ + code: "edge.missing_node", + message: `Edge references non-existent source node: ${edge.from}`, + }); + } + if (!slugs.has(edge.to)) { + issues.push({ + code: "edge.missing_node", + message: `Edge references non-existent target node: ${edge.to}`, + }); + } + if (edge.from === edge.to) { + issues.push({ + code: "edge.self_loop", + message: `Self-loop edge on node: ${edge.from}`, + }); + } + if (edge.weight !== undefined && (edge.weight < 0 || edge.weight > 1)) { + issues.push({ + code: "edge.invalid_weight", + message: `Edge weight out of range [0,1]: ${edge.from} -> ${edge.to} (${edge.weight})`, + }); + } + } + + return { valid: issues.length === 0, issues }; +} + +/** + * Graph Health Metrics — a summary of overall graph quality. + */ +export interface GraphHealthMetrics { + healthScore: number; // 0-100 + connectivity: number; // largest connected component / total nodes (0-1) + density: number; // edges / nodes ratio + freshness: number; // nodes with usable status / total (0-1) + confidenceRatio: number; // edges with weight >= 0.8 / total edges (0-1) + nodeCount: number; + edgeCount: number; + orphanNodes: number; // nodes with no edges + brokenEdges: number; // edges referencing non-existent nodes +} + +/** + * Compute health metrics for a graph index. + * + * - connectivity: BFS from first node, count reachable / total + * - density: edges.length / max(nodes.length, 1) + * - freshness: simplified — nodeCount > 0 ? 1.0 : 0 (full impl needs status data) + * - confidenceRatio: edges with weight >= 0.8 / total edges + * - healthScore = connectivity*30 + (density>1.5?20:density/1.5*20) + freshness*25 + confidenceRatio*25 + * - orphanNodes: nodes not referenced in any edge (from or to) + * - brokenEdges: edges where from or to is not in nodes + */ +export function computeGraphHealth(graph: GraphIndex): GraphHealthMetrics { + const nodeCount = graph.nodes.length; + const edgeCount = graph.edges.length; + const slugSet = new Set(graph.nodes.map((n) => n.slug)); + + // Connectivity: BFS/DFS from first node + let connectivity = 0; + if (nodeCount > 0) { + const adjacency = new Map>(); + for (const node of graph.nodes) { + adjacency.set(node.slug, new Set()); + } + for (const edge of graph.edges) { + if (slugSet.has(edge.from) && slugSet.has(edge.to)) { + adjacency.get(edge.from)!.add(edge.to); + adjacency.get(edge.to)!.add(edge.from); + } + } + + // BFS from the first node + const visited = new Set(); + const queue: string[] = [graph.nodes[0].slug]; + visited.add(graph.nodes[0].slug); + while (queue.length > 0) { + const current = queue.shift()!; + const neighbors = adjacency.get(current); + if (neighbors) { + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + visited.add(neighbor); + queue.push(neighbor); + } + } + } + } + connectivity = visited.size / nodeCount; + } + + // Density + const density = edgeCount / Math.max(nodeCount, 1); + + // Freshness: simplified — if there are nodes, assume 1.0 + const freshness = nodeCount > 0 ? 1.0 : 0; + + // Confidence ratio: edges with weight >= 0.8 / total edges + let confidenceRatio = 0; + if (edgeCount > 0) { + const highConfidenceEdges = graph.edges.filter((e) => (e.weight ?? 0) >= 0.8).length; + confidenceRatio = highConfidenceEdges / edgeCount; + } + + // Orphan nodes: nodes not referenced in any edge + const referencedSlugs = new Set(); + for (const edge of graph.edges) { + referencedSlugs.add(edge.from); + referencedSlugs.add(edge.to); + } + const orphanNodes = graph.nodes.filter((n) => !referencedSlugs.has(n.slug)).length; + + // Broken edges: edges where from or to is not in nodes + const brokenEdges = graph.edges.filter((e) => !slugSet.has(e.from) || !slugSet.has(e.to)).length; + + // Health score + const densityScore = density > 1.5 ? 20 : (density / 1.5) * 20; + const healthScore = connectivity * 30 + densityScore + freshness * 25 + confidenceRatio * 25; + + return { + healthScore, + connectivity, + density, + freshness, + confidenceRatio, + nodeCount, + edgeCount, + orphanNodes, + brokenEdges, + }; +} + +/** + * Load graph-index.json from the wiki's indices directory. + * Returns null if the file doesn't exist. + */ +export async function loadGraphIndex(wikiRoot: string): Promise { + const paths = [ + path.join(wikiRoot, ".teamwiki", ".indices", "graph-index.json"), + path.join(wikiRoot, ".indices", "graph-index.json"), + path.join(wikiRoot, "graph", "graph-index.json"), + ]; + for (const p of paths) { + try { + const raw = await readFile(p, "utf8"); + return JSON.parse(raw) as GraphIndex; + } catch { /* continue */ } + } + return null; +} + +/** + * Save graph-index.json to the wiki's indices directory. + */ +export async function saveGraphIndex(wikiRoot: string, graph: GraphIndex): Promise { + const dir = path.join(wikiRoot, ".teamwiki", ".indices"); + await mkdir(dir, { recursive: true }); + const outPath = path.join(dir, "graph-index.json"); + await writeFile(outPath, JSON.stringify(graph, null, 2), "utf8"); + return outPath; +} + +/** + * Merge two graphs: overlay nodes replace base nodes with same slug. + * + * Edges are deduplicated by `from|to|relation`. When a duplicate is encountered, + * the variant carrying richer evidence wins (overlay-preferred on ties). This + * matters for v1→v2 manifest upgrades: a re-compile that supplies real evidence + * must not be discarded just because an older empty-evidence edge was written + * to the persisted graph first. + */ +export function mergeGraphs(base: GraphIndex, overlay: GraphIndex): GraphIndex { + const nodeMap = new Map(); + for (const n of base.nodes) nodeMap.set(n.slug, n); + for (const n of overlay.nodes) nodeMap.set(n.slug, n); // overlay wins + + const edgeKey = (e: GraphEdge) => `${e.from}|${e.to}|${e.relation}`; + const edgeMap = new Map(); + + const evidenceLen = (e: GraphEdge) => e.evidence?.length ?? 0; + + for (const e of base.edges) { + edgeMap.set(edgeKey(e), e); + } + for (const e of overlay.edges) { + const key = edgeKey(e); + const existing = edgeMap.get(key); + if (!existing) { + edgeMap.set(key, e); + continue; + } + // Prefer the variant with more evidence; on ties, prefer overlay. + if (evidenceLen(e) >= evidenceLen(existing)) { + edgeMap.set(key, e); + } + } + + return { + schemaVersion: GRAPH_INDEX_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + nodes: [...nodeMap.values()], + edges: [...edgeMap.values()], + }; +} diff --git a/src/wiki-engine/core/wiki-protocol.ts b/src/wiki-engine/core/wiki-protocol.ts new file mode 100644 index 0000000..3e446a0 --- /dev/null +++ b/src/wiki-engine/core/wiki-protocol.ts @@ -0,0 +1,197 @@ +import path from "node:path"; + +export type WikiCategory = + | "architecture" + | "component" + | "interface" + | "flow" + | "data" + | "config" + | "error" + | "rule" + | "style" + | "mapping" + | "decision" + | "process" + | "source" + | "query" + | "incident"; + +export type WikiConfidence = "EXTRACTED" | "INFERRED" | "AMBIGUOUS"; +export type WikiReviewState = "draft" | "needs-review" | "accepted"; +export type WikiPageStatus = "draft" | "usable" | "stale" | "deprecated"; + +export const CONFIDENCE_SCORE_DEFAULTS: Record = { + EXTRACTED: 1.0, + INFERRED: 0.75, + AMBIGUOUS: 0.2 +}; + +export type WikiEvidenceType = "definition" | "implementation" | "usage" | "schema" | "config"; + +export interface WikiEvidence { + ref: string; + lineStart?: number; + lineEnd?: number; + commit?: string; + type?: WikiEvidenceType; + /** + * Optional human-readable note explaining the evidence — e.g. why a graph + * edge connects two components. Used by manifest v2 edge.reason translation. + * Renderers that don't recognise this field MUST ignore it (forward-compatible). + */ + note?: string; +} + +export interface WikiPageMetadata { + title: string; + category: WikiCategory; + domain?: string; + project?: string; + tags: string[]; + sources: string[]; + evidence: WikiEvidence[]; + confidence: WikiConfidence; + confidenceScore?: number; + reviewState: WikiReviewState; + status?: WikiPageStatus; + deprecatedBy?: string; + sourceHash?: Record; + created: string; + updated: string; +} + +export interface WikiPageDraft { + slug?: string; + relativePath?: string; + metadata: WikiPageMetadata; + summary?: string; + body: string; + related?: string[]; +} + +export interface LocalAiCommandIssue { + kind: string; + message: string; + sources?: string[]; + refs?: string[]; +} + +export interface LocalAiCommandResult { + ok: boolean; + dryRun: boolean; + command: string; + summary: string; + progressPath?: string; + createdPages: string[]; + updatedPages: string[]; + gaps: Array<{ kind: string; message: string; sources: string[] }>; + conflicts: Array<{ kind: string; message: string; sources: string[] }>; + needsReview: Array<{ kind: string; message: string; refs: string[] }>; + nextActions: string[]; +} + +export type LocalCompilePhase = + | "idle" + | "scanning_code" + | "extracting_facts" + | "writing_wiki_pages" + | "compiling_docs" + | "reconciling" + | "building_context" + | "linting" + | "done" + | "failed"; + +export interface LocalCompileProgress { + phase: LocalCompilePhase; + project: string; + startedAt?: string; + updatedAt: string; + createdPages: string[]; + updatedPages: string[]; + gaps: LocalAiCommandResult["gaps"]; + conflicts: LocalAiCommandResult["conflicts"]; + needsReview: LocalAiCommandResult["needsReview"]; + nextActions: string[]; +} + +export const WIKI_CATEGORIES: WikiCategory[] = [ + "architecture", + "component", + "interface", + "flow", + "data", + "config", + "error", + "rule", + "style", + "mapping", + "decision", + "process", + "source", + "query", + "incident" +]; + +const SAFE_IGNORE_SEGMENTS = new Set([ + ".git", + ".teamwiki", + "node_modules", + "dist", + "build", + ".venv", + "venv", + "coverage", + ".next", + ".turbo" +]); + +const SENSITIVE_FILE_NAMES = new Set(["credentials.json"]); + +export function safeIgnore(filePath: string): boolean { + const normalized = toPosix(filePath); + // Compiled code evidence pages live under .teamwiki/evidence/ and must be writable. + if (normalized.startsWith(".teamwiki/evidence/")) { + return false; + } + const parts = normalized.split("/").filter(Boolean); + if (parts.some((part) => SAFE_IGNORE_SEGMENTS.has(part))) { + return true; + } + const base = parts.at(-1) ?? ""; + if (base.startsWith(".env") || SENSITIVE_FILE_NAMES.has(base)) { + return true; + } + return /\.(pem|key|p12|pfx)$/i.test(base); +} + +export function slugifyWiki(value: string): string { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, "-") + .replace(/^-+|-+$/g, ""); + return slug || "untitled"; +} + +export function wikiPagePath(page: Pick): string { + if (page.relativePath) { + return normalizeRelativePagePath(page.relativePath); + } + const domain = page.metadata.domain ?? page.metadata.project ?? "general"; + const slug = page.slug ?? slugifyWiki(page.metadata.title); + return normalizeRelativePagePath(path.join(domain, `${page.metadata.category}s`, `${slug}.md`)); +} + +export function normalizeRelativePagePath(value: string): string { + const normalized = toPosix(value).replace(/^\/+/, ""); + return normalized.endsWith(".md") ? normalized : `${normalized}.md`; +} + +export function wikiLinkTarget(relativePath: string): string { + return normalizeRelativePagePath(relativePath).replace(/\.md$/i, ""); +} + +export function toPosix(value: string): string { + return value.split(path.sep).join("/"); +} From ef5ac78f57abf606ea7cb443d39d9704f1276d8a Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Wed, 24 Jun 2026 20:08:17 +0800 Subject: [PATCH 05/21] refactor: extract shared utilities and reduce code duplication - Extract autoPushTeamRepo to utils/git.ts (was duplicated in 3 files) - Extract teamwiki template constants to wiki-engine/adapters/templates.ts - Replace inline long strings in import-repo.ts with template functions - Simplify import-iwiki.ts and import.ts push logic --other=code deduplication refactor --- src/codebase-extract.ts | 40 ++++----------------------- src/import-iwiki.ts | 13 ++------- src/import-repo.ts | 34 +++++++++-------------- src/import.ts | 11 +------- src/utils/git.ts | 12 ++++++++ src/wiki-engine/adapters/templates.ts | 33 ++++++++++++++++++++++ 6 files changed, 66 insertions(+), 77 deletions(-) create mode 100644 src/wiki-engine/adapters/templates.ts diff --git a/src/codebase-extract.ts b/src/codebase-extract.ts index 6f4a02a..1b7455c 100644 --- a/src/codebase-extract.ts +++ b/src/codebase-extract.ts @@ -18,6 +18,7 @@ import { detectCodeIncrementalChanges, } from './wiki-engine/adapters/index.js'; import type { CodeFact, CodeGraphIndex } from './wiki-engine/adapters/index.js'; +import { routerTemplate, indexTemplate, HOT_TEMPLATE } from './wiki-engine/adapters/templates.js'; export interface ExtractCodebaseOptions { path?: string; @@ -282,41 +283,10 @@ export async function extractCodebase(opts: ExtractCodebaseOptions): Promise d.title).slice(0, 3).join(', ')}`, - ['.'], - ); - log.success('已推送到团队仓库'); - } catch (err) { - log.debug(`[push] 自动推送失败(可手动 teamai push): ${err instanceof Error ? err.message : err}`); - } + const { autoPushTeamRepo } = await import('./utils/git.js'); + await autoPushTeamRepo(repoPath, `[teamai] Import from iWiki: ${documents.map(d => d.title).slice(0, 3).join(', ')}`); } log.success('iWiki 导入完成'); diff --git a/src/import-repo.ts b/src/import-repo.ts index 8da2a77..5d82d57 100644 --- a/src/import-repo.ts +++ b/src/import-repo.ts @@ -716,32 +716,33 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise await fs.remove(cacheWiki); } // 更新顶层 router.md 和 index.md(追加新项目,不覆盖) + const { routerTemplate, indexTemplate, HOT_TEMPLATE } = await import('./wiki-engine/adapters/templates.js'); const routerPath = path.join(teamwikiRoot, 'router.md'); const indexPath = path.join(teamwikiRoot, 'index.md'); const projectLink = `[[code/${slug}/index]]`; if (await fs.pathExists(routerPath)) { const router = await fs.readFile(routerPath, 'utf8'); if (!router.includes(projectLink)) { - await fs.writeFile(routerPath, router.trimEnd() + `\n- ${projectLink} — ${slug} 代码知识\n`, 'utf8'); + const line = `- ${projectLink} — ${slug} 代码知识\n`; + await fs.writeFile(routerPath, router.trimEnd() + '\n' + line, 'utf8'); } } else { - await fs.writeFile(routerPath, `# Team Wiki Router\n\nRoute broad questions to the relevant domain entrypoint.\n\n- ${projectLink} — ${slug} 代码知识\n`, 'utf8'); + await fs.writeFile(routerPath, routerTemplate([{ slug, label: slug }]), 'utf8'); } if (await fs.pathExists(indexPath)) { const idx = await fs.readFile(indexPath, 'utf8'); - if (!idx.includes(`${slug}`)) { + if (!idx.includes(slug)) { const insertPoint = idx.indexOf('## Navigation'); if (insertPoint > 0) { - const before = idx.slice(0, insertPoint); - const after = idx.slice(insertPoint); - await fs.writeFile(indexPath, before + `- [${slug}](./evidence/code/${slug}/index.md) — 代码知识图谱\n\n` + after, 'utf8'); + const entry = `- [${slug}](./evidence/code/${slug}/index.md) — 代码知识图谱\n\n`; + await fs.writeFile(indexPath, idx.slice(0, insertPoint) + entry + idx.slice(insertPoint), 'utf8'); } } } else { - await fs.writeFile(indexPath, `# Team Wiki Index\n\nLast updated: ${new Date().toISOString()}\n\n## Domains\n\n- [${slug}](./evidence/code/${slug}/index.md) — 代码知识图谱\n\n## Navigation\n\n- [router.md](./router.md) — 领域路由入口\n- [hot.md](./hot.md) — 活跃工作记忆\n`, 'utf8'); + await fs.writeFile(indexPath, indexTemplate([{ slug, label: slug }]), 'utf8'); } if (!await fs.pathExists(path.join(teamwikiRoot, 'hot.md'))) { - await fs.writeFile(path.join(teamwikiRoot, 'hot.md'), '# Hot Context\n\nKeep only active working memory here: current focus, recent decisions, open questions.\nMove durable conclusions into domain pages.\n', 'utf8'); + await fs.writeFile(path.join(teamwikiRoot, 'hot.md'), HOT_TEMPLATE, 'utf8'); } log.info(chalk.green(`✓ teamwiki/ 知识图谱已更新: ${slug}`)); @@ -946,19 +947,10 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise } catch { /* 非关键路径 */ } // 9. 自动推送所有产物到团队仓库 - try { - const { pushRepoDirectly } = await import('./utils/git.js'); - const teamRepoPath = path.join(domainsBase, '.teamai', 'team-repo'); - if (await fs.pathExists(teamRepoPath)) { - await pushRepoDirectly( - teamRepoPath, - `[teamai] Import codebase knowledge from ${owner}/${repoName}`, - ['.'], - ); - log.success('已推送到团队仓库'); - } - } catch (err) { - log.debug(`[push] 自动推送失败(可手动 teamai push): ${err instanceof Error ? err.message : err}`); + const teamRepoPath = path.join(domainsBase, '.teamai', 'team-repo'); + if (await fs.pathExists(teamRepoPath)) { + const { autoPushTeamRepo } = await import('./utils/git.js'); + await autoPushTeamRepo(teamRepoPath, `[teamai] Import codebase knowledge from ${owner}/${repoName}`); } } } diff --git a/src/import.ts b/src/import.ts index 69f18ad..9f746f6 100644 --- a/src/import.ts +++ b/src/import.ts @@ -13,16 +13,7 @@ import { importFromOrg } from './import-org.js'; import { importFromIWikiDual } from './iwiki-dual.js'; import { GlobalOptions } from './types.js'; import { log } from './utils/logger.js'; -import { pushRepoDirectly } from './utils/git.js'; - -async function autoPushTeamRepo(repoPath: string, message: string): Promise { - try { - await pushRepoDirectly(repoPath, message, ['.']); - log.success('已推送到团队仓库'); - } catch (err) { - log.debug(`[push] 自动推送失败(可手动 teamai push): ${err instanceof Error ? err.message : err}`); - } -} +import { autoPushTeamRepo } from './utils/git.js'; /** * import 命令的扩展选项,合并全局选项与子命令专属选项。 diff --git a/src/utils/git.ts b/src/utils/git.ts index 7de55d9..97074f0 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -142,6 +142,18 @@ export async function pushRepoDirectly(localPath: string, message: string, files await git.push(['-u', 'origin', branch]); } +/** + * Best-effort push all changes in a team repo clone. + * Logs success/failure without throwing. + */ +export async function autoPushTeamRepo(repoPath: string, message: string): Promise { + try { + await pushRepoDirectly(repoPath, message, ['.']); + } catch { + // non-blocking: user can manually run teamai push + } +} + /** * Create a new branch, commit files, and push the branch to remote. * Returns false if there are no changes to commit. diff --git a/src/wiki-engine/adapters/templates.ts b/src/wiki-engine/adapters/templates.ts new file mode 100644 index 0000000..35c35dd --- /dev/null +++ b/src/wiki-engine/adapters/templates.ts @@ -0,0 +1,33 @@ +export function routerTemplate(projects: Array<{ slug: string; label: string }>): string { + const links = projects.map(p => `- [[code/${p.slug}/index]] — ${p.label} 代码知识`).join('\n'); + return `# Team Wiki Router\n\nRoute broad questions to the relevant domain entrypoint.\n\n${links}\n`; +} + +export function indexTemplate(projects: Array<{ slug: string; label: string }>): string { + const domains = projects + .map(p => `- [${p.slug}](./evidence/code/${p.slug}/index.md) — 代码知识图谱`) + .join('\n'); + return [ + '# Team Wiki Index', + '', + `Last updated: ${new Date().toISOString()}`, + '', + '## Domains', + '', + domains, + '', + '## Navigation', + '', + '- [router.md](./router.md) — 领域路由入口', + '- [hot.md](./hot.md) — 活跃工作记忆', + '', + ].join('\n'); +} + +export const HOT_TEMPLATE = [ + '# Hot Context', + '', + 'Keep only active working memory here: current focus, recent decisions, open questions.', + 'Move durable conclusions into domain pages.', + '', +].join('\n'); From 5d4a30462a981359a5a6cd977af67a8447ad86af Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Wed, 24 Jun 2026 20:37:16 +0800 Subject: [PATCH 06/21] fix(import-repo): write teamwiki to team-repo clone and skip domain recommendation - teamwikiRoot now points to .teamai/team-repo/teamwiki/ (not cwd/) so autoPush correctly includes graph artifacts - Push moved before domain recommendation step - Domain recommendation still runs but no longer blocks the main flow --other=fix teamwiki push path and domain skip --- src/import-repo.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/import-repo.ts b/src/import-repo.ts index 5d82d57..93818ff 100644 --- a/src/import-repo.ts +++ b/src/import-repo.ts @@ -680,10 +680,11 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise } } - // 4b. 生成 teamwiki/ 知识图谱产物 + // 4b. 生成 teamwiki/ 知识图谱产物(写入 team-repo 以便自动 push) + const teamRepoDir = path.join(process.cwd(), '.teamai', 'team-repo'); const teamwikiRoot = output ? path.resolve(output, '..', 'teamwiki') - : path.join(process.cwd(), 'teamwiki'); + : path.join(teamRepoDir, 'teamwiki'); if (!dryRun) { const cacheWiki = path.join(cacheDir, 'teamwiki'); try { @@ -753,7 +754,18 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise } } - // 5. 业务域推荐 + // 5. 自动推送所有产物到团队仓库 + if (!dryRun) { + const pushTarget = path.join(process.cwd(), '.teamai', 'team-repo'); + if (await fs.pathExists(pushTarget)) { + const { autoPushTeamRepo } = await import('./utils/git.js'); + await autoPushTeamRepo(pushTarget, `[teamai] Import codebase knowledge from ${owner}/${repoName}`); + } + } + + log.info(chalk.green(`✓ 仓库 ${owner}/${repoName} 导入完成`)); + + // 5-legacy. 业务域推荐(旧 docs/team-codebase 体系,保留兼容) const cwd = process.cwd(); // 当无 --output 时,domains.yaml 写入团队仓库(共享),否则写入 cwd let domainsBase = cwd; @@ -932,7 +944,6 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise console.log(chalk.yellow('[dry-run] 跳过写盘(domains.yaml / LAST_SYNC)')); } - log.info(chalk.green(`✓ 仓库 ${owner}/${repoName} 导入完成`)); // 8. 更新聚合文件(domain-*.md + index.md) if (!dryRun) { @@ -946,11 +957,6 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise log.info(`聚合文件已更新`); } catch { /* 非关键路径 */ } - // 9. 自动推送所有产物到团队仓库 - const teamRepoPath = path.join(domainsBase, '.teamai', 'team-repo'); - if (await fs.pathExists(teamRepoPath)) { - const { autoPushTeamRepo } = await import('./utils/git.js'); - await autoPushTeamRepo(teamRepoPath, `[teamai] Import codebase knowledge from ${owner}/${repoName}`); - } + // push 已在步骤 5 执行,此处不再重复 } } From d7fe8307039ac1302fb015d80b1c8a2311f5fca0 Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Wed, 24 Jun 2026 20:53:21 +0800 Subject: [PATCH 07/21] fix(import-mr): strip markdown code block wrapper from AI learning output AI sometimes wraps the learning draft in ```markdown...``` code fences, causing gray-matter YAML parsing to crash. Strip the outer fence before attempting frontmatter extraction. --other=fix AI output parsing edge case --- src/import-mr.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/import-mr.ts b/src/import-mr.ts index c3c7011..ff5ae94 100644 --- a/src/import-mr.ts +++ b/src/import-mr.ts @@ -313,6 +313,10 @@ export async function importFromMR(opts: { } // ── 步骤 3:解析 learning 草稿 + dedup ───────────────── + // AI 可能用 markdown 代码块包裹输出,先剥离 + learningContent = learningContent + .replace(/^```(?:markdown|md|yaml)?\s*\n/m, '') + .replace(/\n```\s*$/, ''); // AI 可能在 frontmatter 前输出对话性废话,截取从第一个 `---` 开始的内容 const frontmatterStart = learningContent.indexOf('---'); if (frontmatterStart > 0) { From 95b4c5428b017e4721cf1f3cbf1e98494a702458 Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Wed, 24 Jun 2026 21:16:36 +0800 Subject: [PATCH 08/21] fix(clone): upgrade http to https for netrc auth + convert to SSH when forceSsh - TGit API returns http:// URLs but netrc only works with HTTPS - forceSsh now properly converts HTTP/HTTPS URLs to git@host:path format --other=fix clone auth for TGit repos --- src/clone.ts | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/clone.ts b/src/clone.ts index a8880e2..aa15000 100644 --- a/src/clone.ts +++ b/src/clone.ts @@ -3,6 +3,7 @@ import { spawn } from 'node:child_process'; import fs from 'fs-extra'; import { getGitHubToken } from './providers/github/gh-cli.js'; +import { gfGetOAuthToken } from './providers/tgit/gf-cli.js'; import { log } from './utils/logger.js'; // ─── Types ────────────────────────────────────────────── @@ -36,6 +37,18 @@ function isSshUrl(url: string): boolean { return url.startsWith('git@') || (!url.includes('://') && url.includes(':')); } +/** + * 将 HTTP/HTTPS URL 转换为 SSH 格式。 + * 如 https://git.woa.com/HAI/hai_api.git → git@git.woa.com:HAI/hai_api.git + */ +function convertHttpToSsh(url: string): string { + const match = url.match(/^https?:\/\/([^/]+)\/(.+)$/); + if (match) { + return `git@${match[1]}:${match[2]}`; + } + return url; +} + /** * 将 URL 中的认证信息脱敏,用于日志和错误消息。 * 替换 https://[anything]@ 为 https://***@ @@ -156,9 +169,9 @@ export async function shallowClone( let githubToken: string | undefined; if (forceSsh || isSshUrl(url)) { - cloneUrl = url; + cloneUrl = isSshUrl(url) ? url : convertHttpToSsh(url); cloneMethod = 'ssh'; - log.debug(`shallowClone: 使用 SSH 克隆 ${url}`); + log.debug(`shallowClone: 使用 SSH 克隆 ${cloneUrl}`); } else if (forceAnonymous) { cloneUrl = url; cloneMethod = 'https-anonymous'; @@ -175,9 +188,21 @@ export async function shallowClone( cloneMethod = 'https-anonymous'; log.debug(`shallowClone: 使用匿名 HTTPS 克隆 github 仓库`); } + } else if (provider === 'tgit') { + // TGit: 使用 OAuth token 嵌入 URL(netrc 非标准字段导致 git credential 不稳定) + const tgitToken = gfGetOAuthToken(); + cloneUrl = url.replace(/^http:\/\//, 'https://'); + if (tgitToken) { + cloneUrl = cloneUrl.replace('https://', `https://oauth2:${tgitToken}@`); + cloneMethod = 'https-token'; + log.debug(`shallowClone: 使用 HTTPS+token 克隆 tgit 仓库`); + } else { + cloneMethod = 'https-anonymous'; + log.debug(`shallowClone: 无 TGit token,尝试匿名 HTTPS 克隆`); + } } else { - // tgit 或其他 provider,依赖 ~/.netrc - cloneUrl = url; + // 其他 provider,依赖 ~/.netrc + cloneUrl = url.replace(/^http:\/\//, 'https://'); cloneMethod = 'https-anonymous'; log.debug(`shallowClone: 使用 HTTPS (~/.netrc) 克隆 ${provider} 仓库`); } From ea56672aed438ca4b39bca8db6afe5521078a09f Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Thu, 25 Jun 2026 10:33:42 +0800 Subject: [PATCH 09/21] docs: update README with knowledge graph commands and descriptions --- README.md | 19 ++++++++++--------- README.zh-CN.md | 19 ++++++++++--------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index eb12863..e553c1b 100644 --- a/README.md +++ b/README.md @@ -78,18 +78,19 @@ The CLI picks a provider automatically from the repo URL: | `teamai roles` | Manage team roles (`init`/`list`/`set`/`add`/`remove`/`update`) | | `teamai source` | Manage cross-team skill subscription sources (`add`/`remove`/`list`/`browse`) | | `teamai contribute --file [--scope ]` | Push an AI-generated experience document to the team repo | -| `teamai recall ` | Search the team knowledge base, automatically merging user + project scope results | -| `teamai import --from-repo ` | Clone a remote repo and generate a per-repo summary under `docs/team-codebase/repos/.md`; AI recommends a business domain and persists the assignment to `.teamai/domains.yaml` | -| `teamai import --from-repo-list ` | Batch import a whitelist of repos with concurrency control, then aggregate the results into per-domain views | -| `teamai import --from-org --bootstrap` | List every repo under an organization (GitHub or TGit), AI-cluster them into business domains, and run an interactive review before the first full sync | -| `teamai import --from-iwiki [--iwiki-dual]` | Import iWiki documents as learnings; in dual mode also extract business-API / external-knowledge / glossary sections into `docs/team-codebase/external-knowledge.md` | +| `teamai recall [--depth route\|context\|lookup]` | Search the team knowledge base (learnings + skills + docs + rules + codebase graph). Codebase results use BM25 + graph-neighbor boosting | +| `teamai import --from-repo ` | Clone a remote repo, build a code knowledge graph (`teamwiki/`), and auto-push to team repo. Extracts components, interfaces, configs, errors, and import relations | +| `teamai import --from-repo-list ` | Batch import repos from a whitelist with concurrency control; cross-repo dependency edges auto-detected | +| `teamai import --from-org ` | List every repo under an organization (GitHub or TGit), AI-cluster into domains, then batch import with knowledge graph construction | +| `teamai import --from-iwiki ` | Import iWiki documents as learnings; auto-reconcile MAPS_TO edges between doc terms and code knowledge graph nodes | +| `teamai codebase --extract [path]` | Deterministic code fact extraction (TS/Python/Go/Rust/Java) → `teamwiki/` with evidence pages + graph-index.json + knowledge gaps | +| `teamai codebase --lint` | Knowledge graph health check: node connectivity, stale manifest, navigation files, orphan detection | +| `teamai codebase --upgrade-wiki` | Migrate from old `docs/team-codebase/` format to the new `teamwiki/` knowledge graph | | `teamai cache --status \| --gc` | Inspect or garbage-collect the shallow-clone cache at `~/.teamai/cache/repos/` (LRU + size cap, default 5GB) | -| `teamai codebase --lint [--fix]` | Cross-file consistency lint over `docs/team-codebase` and `.teamai/`; reports anchor / orphan / source-invalid / sync-stale issues; `--fix` applies low-risk mechanical fixes | -| `teamai review [id] [--apply \| --reject \| --all-apply]` | Inspect and process pending codebase changes from `.teamai/pending-review.jsonl`; `--apply` patches in place via section anchors | -| `teamai domains drift [url] [--apply \| --lock \| --apply-all]` | Inspect and resolve domain-drift signals; `--apply` reassigns the repo to the recommended domain and refreshes the aggregate views | +| `teamai review [id] [--apply \| --reject \| --all-apply]` | Inspect and process pending codebase changes from `.teamai/pending-review.jsonl` | | `teamai digest` | Generate a team AI usage weekly digest (skill leaderboard, new/updated skills, session summaries) | | `teamai hooks` | Manage AI-tool hooks (list / inject / remove) | -| `teamai ci extract-mr --url [--mode comment\|write\|both] [--individual-comments]` | CI pipeline integration: extract knowledge from MR/PR, post as comments, and write to team repo after merge. With `--individual-comments`, each suggestion is posted separately with reaction/reject support (GitHub 👎 / TGit ☝️) | +| `teamai ci extract-mr --url [--mode comment\|write\|both] [--individual-comments]` | CI pipeline: extract learning + graph changes from MR/PR, post as comments (with reaction/reject), write to team repo after merge | | `teamai uninstall [--force]` | Uninstall teamai: remove hooks, rules, skills, env, docs, and `~/.teamai/` | | `teamai doctor` | Diagnose configuration problems | diff --git a/README.zh-CN.md b/README.zh-CN.md index c4ae919..3965526 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -78,18 +78,19 @@ CLI 会根据用户传入的 repo URL 自动选择 provider: | `teamai roles` | 管理团队角色(`init`/`list`/`set`/`add`/`remove`/`update`) | | `teamai source` | 管理跨团队 skill 订阅源(`add`/`remove`/`list`/`browse`) | | `teamai contribute --file [--scope ]` | 将 AI 生成的经验文档推送到团队仓库 | -| `teamai recall ` | 搜索团队知识库,自动合并 user + project 双 scope 结果 | -| `teamai import --from-repo ` | 拉取远端仓库并生成单仓视图 `docs/team-codebase/repos/.md`;AI 推荐业务域并写入 `.teamai/domains.yaml` | -| `teamai import --from-repo-list ` | 按白名单批量导入多个仓库(支持并发),并按业务域聚合产出 | -| `teamai import --from-org --bootstrap` | 列出组织/group 下所有仓库(GitHub / TGit),AI 聚类为业务域,交互式 review 后完成首次全量同步 | -| `teamai import --from-iwiki [--iwiki-dual]` | 把 iWiki 文档导入为 learnings;dual 模式同时把业务接口 / 外部知识源 / 术语表抽取到 `docs/team-codebase/external-knowledge.md` | +| `teamai recall [--depth route\|context\|lookup]` | 搜索团队知识库(learnings + skills + docs + rules + codebase 图谱)。代码知识使用 BM25 + 图谱邻居加权检索 | +| `teamai import --from-repo ` | 拉取远端仓库,构建代码知识图谱(`teamwiki/`),自动推送到团队仓库。提取组件、接口、配置、错误类型和 import 依赖关系 | +| `teamai import --from-repo-list ` | 按白名单批量导入多个仓库(支持并发);自动检测跨仓依赖边 | +| `teamai import --from-org ` | 列出组织/group 下所有仓库(GitHub / TGit),AI 聚类为业务域,批量构建知识图谱 | +| `teamai import --from-iwiki ` | 把 iWiki 文档导入为 learnings;自动与代码知识图谱建立 MAPS_TO 映射关系 | +| `teamai codebase --extract [path]` | 确定性代码知识提取(TS/Python/Go/Rust/Java)→ `teamwiki/` 产物:evidence 页面 + graph-index.json + 知识缺口检测 | +| `teamai codebase --lint` | 知识图谱健康度检查:节点连通性、manifest 过期、导航文件完整性、孤立节点 | +| `teamai codebase --upgrade-wiki` | 从旧 `docs/team-codebase/` 格式迁移到新 `teamwiki/` 知识图谱 | | `teamai cache --status \| --gc` | 查看或回收 shallow-clone 缓存目录 `~/.teamai/cache/repos/`(LRU + 容量上限,默认 5GB) | -| `teamai codebase --lint [--fix]` | 对 `docs/team-codebase` 与 `.teamai/` 做跨文件一致性 lint;报告锚点 / 孤儿 / 源失效 / 同步陈旧等问题;`--fix` 应用低风险机械修复 | -| `teamai review [id] [--apply \| --reject \| --all-apply]` | 浏览并处理 `.teamai/pending-review.jsonl` 中的待审 codebase 变更;`--apply` 通过章节锚点原地写入 | -| `teamai domains drift [url] [--apply \| --lock \| --apply-all]` | 浏览并处理域漂移信号;`--apply` 把仓库重新归类到推荐域并刷新聚合视图 | +| `teamai review [id] [--apply \| --reject \| --all-apply]` | 浏览并处理 `.teamai/pending-review.jsonl` 中的待审变更 | | `teamai digest` | 生成团队 AI 使用周报(skill 排行、新增/更新 skill、session 摘要) | | `teamai hooks` | 管理 AI 工具 hooks(list / inject / remove) | -| `teamai ci extract-mr --url [--mode comment\|write\|both] [--individual-comments]` | CI 流水线集成:从 MR/PR 中提取知识,发布为评论,合并后写入团队知识仓库。使用 `--individual-comments` 时每条建议单独发布,支持 reaction/reject 交互(GitHub 👎 / TGit ☝️) | +| `teamai ci extract-mr --url [--mode comment\|write\|both] [--individual-comments]` | CI 流水线:从 MR/PR 提取 learning + 图谱变更,发布评论(支持 reaction/reject),合并后写入团队仓库 | | `teamai uninstall [--force]` | 卸载 teamai:移除 hooks、rules、skills、env、docs、~/.teamai/ | | `teamai doctor` | 诊断配置问题 | From 60eb995bd59c305f023156fa216cc2e711b3a5b1 Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Thu, 25 Jun 2026 10:51:59 +0800 Subject: [PATCH 10/21] fix: mergeGraphs node key fallback + domains.yaml error tolerance - mergeGraphs: use n.slug ?? n.id ?? fallback as node dedup key (CodeGraphIndex nodes have 'id' not 'slug', causing all nodes to collapse into one entry when merging multi-repo graphs) - import-repo: catch domains.yaml load errors gracefully (http:// URLs from TGit API fail schema validation, skip domain recommendation) --other=fix multi-repo graph merge and domain validation --- src/import-repo.ts | 8 +++++++- src/wiki-engine/core/graph-index.schema.ts | 5 +++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/import-repo.ts b/src/import-repo.ts index 93818ff..7ebd5dd 100644 --- a/src/import-repo.ts +++ b/src/import-repo.ts @@ -780,7 +780,13 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise domainsBase = lc.repo.localPath; } catch { /* fallback: cwd */ } } - const existingDomains = await loadDomains(domainsBase); + let existingDomains: DomainsFile; + try { + existingDomains = await loadDomains(domainsBase); + } catch { + // domains.yaml 可能不存在或格式不兼容(旧 http:// URL),跳过域推荐 + return; + } // 修正产物路径:使用 domainsBase(team-repo)作为输出根 if (!output && domainsBase !== cwd) { diff --git a/src/wiki-engine/core/graph-index.schema.ts b/src/wiki-engine/core/graph-index.schema.ts index b22fb7d..b6ec260 100644 --- a/src/wiki-engine/core/graph-index.schema.ts +++ b/src/wiki-engine/core/graph-index.schema.ts @@ -384,8 +384,9 @@ export async function saveGraphIndex(wikiRoot: string, graph: GraphIndex): Promi */ export function mergeGraphs(base: GraphIndex, overlay: GraphIndex): GraphIndex { const nodeMap = new Map(); - for (const n of base.nodes) nodeMap.set(n.slug, n); - for (const n of overlay.nodes) nodeMap.set(n.slug, n); // overlay wins + const nodeKey = (n: GraphNode) => n.slug ?? (n as unknown as { id?: string }).id ?? `${n.title}:${n.type}`; + for (const n of base.nodes) nodeMap.set(nodeKey(n), n); + for (const n of overlay.nodes) nodeMap.set(nodeKey(n), n); // overlay wins const edgeKey = (e: GraphEdge) => `${e.from}|${e.to}|${e.relation}`; const edgeMap = new Map(); From 1d8d181f19ba277b4200ff9ce64c676374cf7d1d Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Thu, 25 Jun 2026 11:04:05 +0800 Subject: [PATCH 11/21] refactor(import-org): remove AI clustering, use direct whitelist import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Knowledge graph (teamwiki/) organizes repo relationships via nodes/edges, making AI domain clustering redundant. Simplified flow: list repos → filter → generate whitelist → batch import Skipped 3 tests that validated the removed clustering logic. --other=remove AI clustering from import-org --- src/__tests__/import-org.test.ts | 6 +-- src/import-org.ts | 87 ++++++-------------------------- 2 files changed, 19 insertions(+), 74 deletions(-) diff --git a/src/__tests__/import-org.test.ts b/src/__tests__/import-org.test.ts index 9f22b86..c6ba448 100644 --- a/src/__tests__/import-org.test.ts +++ b/src/__tests__/import-org.test.ts @@ -112,7 +112,7 @@ describe('importFromOrg', () => { await fs.remove(cwd); }); - it('过滤 archived 仓库后传给 clusterRepos', async () => { + it.skip('过滤 archived 仓库后传给 clusterRepos', async () => { const repos: OrgRepoInfo[] = [ makeRepo({ url: 'https://github.com/org/active', fullName: 'org/active', name: 'active', archived: false }), makeRepo({ url: 'https://github.com/org/archived', fullName: 'org/archived', name: 'archived', @@ -139,7 +139,7 @@ describe('importFromOrg', () => { expect(callArg.some((r: unknown) => (r as { name: string }).name === 'archived')).toBe(false); }); - it('includePattern + excludePattern 共同生效', async () => { + it.skip('includePattern + excludePattern 共同生效', async () => { const repos: OrgRepoInfo[] = [ makeRepo({ url: 'https://github.com/org/service-a', fullName: 'org/service-a', name: 'service-a' }), makeRepo({ url: 'https://github.com/org/service-b', fullName: 'org/service-b', name: 'service-b' }), @@ -177,7 +177,7 @@ describe('importFromOrg', () => { expect(reviewDomains).not.toHaveBeenCalled(); }); - it('bootstrap=true 调用 reviewDomains 且 finalize=save 时写正式配置', async () => { + it.skip('bootstrap=true 调用 reviewDomains 且 finalize=save 时写正式配置', async () => { mockListOrgRepos.mockResolvedValue([makeRepo()]); await importFromOrg({ diff --git a/src/import-org.ts b/src/import-org.ts index be0ec08..1f143d6 100644 --- a/src/import-org.ts +++ b/src/import-org.ts @@ -242,80 +242,25 @@ export async function importFromOrg(opts: ImportFromOrgOptions): Promise { return; } - log.info(`过滤后剩余 ${filteredRepos.length} 个仓库,开始 AI 聚类...`); + log.info(`过滤后剩余 ${filteredRepos.length} 个仓库,生成白名单...`); - // 4. 转换 RepoMeta 并聚类 - const repoMetas: RepoMeta[] = filteredRepos.map(toRepoMeta); - let domainsDraft: DomainsFile; - try { - domainsDraft = await clusterRepos(repoMetas); - } catch (err) { - throw new Error(`AI 聚类失败: ${String(err)}`); - } - - // 5. 写草稿 + // 4. 生成白名单(跳过 AI 聚类,知识图谱通过 nodes/edges 自动组织关系) + const whitelistDraftPath = path.join(cwd, WHITELIST_DRAFT_PATH); if (!opts.dryRun) { - await saveDomainsDraft(cwd, domainsDraft); - const whitelistDraftPath = path.join(cwd, WHITELIST_DRAFT_PATH); await fs.ensureDir(path.dirname(whitelistDraftPath)); - await fs.writeFile( - whitelistDraftPath, - buildWhitelistYaml(filteredRepos, domainsDraft), - 'utf8', - ); - log.info(`草稿已写入:.teamai/domains.draft.yaml + .teamai/repo-whitelist.draft.yaml`); - } else { - log.info('[dry-run] 跳过草稿写入'); - } - - let finalAction: 'save' | 'draft' | 'abort' = 'draft'; - - // 6. 若 bootstrap=true,进 reviewDomains - if (opts.bootstrap) { - const { result, finalize } = await reviewDomains(domainsDraft); - finalAction = finalize; - - if (finalize === 'save') { - if (!opts.dryRun) { - await saveDomains(cwd, result); - // 写正式白名单 - const whitelistPath = path.join(cwd, WHITELIST_PATH); - await fs.ensureDir(path.dirname(whitelistPath)); - await fs.writeFile( - whitelistPath, - buildWhitelistYaml(filteredRepos, result), - 'utf8', - ); - // 删除草稿 - const draftPath = path.join(cwd, WHITELIST_DRAFT_PATH); - if (await fs.pathExists(draftPath)) { - await fs.remove(draftPath); - } - log.success('正式配置已写入:.teamai/domains.yaml + .teamai/repo-whitelist.yaml'); - } else { - log.info('[dry-run] 跳过正式配置写入'); - } - } else if (finalize === 'abort') { - // 删除两份草稿 - if (!opts.dryRun) { - const draftDomains = path.join(cwd, '.teamai/domains.draft.yaml'); - const draftWhitelist = path.join(cwd, WHITELIST_DRAFT_PATH); - const removeDraft = async (p: string): Promise => { - if (await fs.pathExists(p)) await fs.remove(p); - }; - await Promise.all([removeDraft(draftDomains), removeDraft(draftWhitelist)]); - log.info('已放弃,草稿已删除'); - } - } else { - log.info('已保留草稿,可稍后手动编辑后导入'); + const lines = ['version: 1', 'repos:']; + for (const repo of filteredRepos) { + lines.push(` - url: ${repo.url}`); + lines.push(` auth: token`); + lines.push(` priority: normal`); } + await fs.writeFile(whitelistDraftPath, lines.join('\n') + '\n', 'utf8'); + log.info(`白名单已写入:${WHITELIST_DRAFT_PATH}(${filteredRepos.length} 个仓库)`); } - // 7. 若未 abort 且非 skipImport,调 importFromRepoList - if (!opts.skipImport && finalAction !== 'abort') { - const whitelistPath = opts.dryRun - ? path.join(cwd, WHITELIST_DRAFT_PATH) - : path.join(cwd, finalAction === 'save' ? WHITELIST_PATH : WHITELIST_DRAFT_PATH); + // 5. 批量导入 + if (!opts.skipImport) { + const whitelistPath = whitelistDraftPath; if (await fs.pathExists(whitelistPath)) { log.info(`开始批量导入(白名单:${whitelistPath})...`); @@ -349,10 +294,10 @@ export async function importFromOrg(opts: ImportFromOrgOptions): Promise { event: 'bootstrap-complete', org: opts.org, repo_count: filteredRepos.length, - domain_count: domainsDraft.domains.length, - final_action: finalAction, + + }, }); - log.success(`组织级初始化完成(${filteredRepos.length} 仓库 / ${domainsDraft.domains.length} 个域)`); + log.success(`组织级初始化完成(${filteredRepos.length} 仓库)`); } From f64ca18d8ebe39443b54c4a119229f7fd8493c01 Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Thu, 25 Jun 2026 11:46:48 +0800 Subject: [PATCH 12/21] fix(import-repo): AI scan timeout no longer blocks graph extraction - Increase AI call timeout from 720s to 1200s (large repos need more time) - generateCodebaseMd failure now warns instead of throwing, allowing extractCodebase (deterministic graph extraction) to proceed regardless - docs/team-codebase write skipped when AI scan fails; teamwiki/ graph extraction still runs successfully --other=fix large repo import resilience --- src/import-repo.ts | 61 +++++++++++++++++------------------------- src/utils/ai-client.ts | 2 +- 2 files changed, 25 insertions(+), 38 deletions(-) diff --git a/src/import-repo.ts b/src/import-repo.ts index 7ebd5dd..8039cc1 100644 --- a/src/import-repo.ts +++ b/src/import-repo.ts @@ -581,57 +581,43 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise return; } - // 3. 扫描生成 codebase.md + // 3. 扫描生成 codebase.md(AI 扫描失败不阻断后续图谱提取) log.info(`扫描仓库内容...`); - let codebaseMd: string; + let codebaseMd: string | undefined; try { codebaseMd = await generateCodebaseMd({ repoPath: cacheDir }); } catch (err) { - // 保留缓存便于排查 - throw new Error(`codebase 扫描失败: ${err instanceof Error ? err.message : String(err)}`); + log.warn(`AI codebase 扫描失败(不阻断图谱提取): ${err instanceof Error ? err.message : String(err)}`); } - // 4. 确定产物输出路径(优先写入 team-repo/docs/team-codebase) - // 注:outputRoot 使用后续步骤 5 中 domainsBase 同源的 team-repo 路径 - // 这里先用临时值,待 domainsBase 确定后再修正 + // 4. 写入 docs/team-codebase 叙事文档(AI 扫描成功时) const outputRoot = output ?? path.join(process.cwd(), 'docs', 'team-codebase'); let repoMdPath = path.join(outputRoot, 'repos', `${slug}.md`); - // path-safety:确保写入路径在 reposDir 内,防止 slug 含路径分隔符导致目录穿越 - assertSafePath(repoMdPath, [path.join(outputRoot, 'repos')]); - // 章节级 diff + 锚点合并 - const sourceTag = `${url}@${cloneSha.slice(0, 8)}`; - const syncedAt = new Date().toISOString(); + if (codebaseMd) { + assertSafePath(repoMdPath, [path.join(outputRoot, 'repos')]); + const sourceTag = `${url}@${cloneSha.slice(0, 8)}`; + const syncedAt = new Date().toISOString(); - let oldFile: string | null = null; - if (await fs.pathExists(repoMdPath)) { - try { - oldFile = await fs.readFile(repoMdPath, 'utf8'); - } catch { - oldFile = null; + let oldFile: string | null = null; + if (await fs.pathExists(repoMdPath)) { + try { oldFile = await fs.readFile(repoMdPath, 'utf8'); } catch { oldFile = null; } } - } - let merged: ReturnType; - let toWrite: string; - try { - merged = mergeWithAnchors(oldFile, codebaseMd, { source: sourceTag, syncedAt }); - toWrite = merged.mergedMd; - } catch (err) { - log.warn(`[section-merge] ${err instanceof Error ? err.message : err};fallback 到全量重写`); - // fallback 前备份旧文件,防止已有章节数据丢失 - if (oldFile !== null && !dryRun) { - const bakPath = `${repoMdPath}.bak`; - try { - await fs.writeFile(bakPath, oldFile, 'utf8'); - log.warn(`[section-merge] 旧文件已备份至:${bakPath}`); - } catch (bakErr) { - log.debug(`[section-merge] 备份失败:${bakErr instanceof Error ? bakErr.message : bakErr}`); + let merged: ReturnType; + let toWrite: string; + try { + merged = mergeWithAnchors(oldFile, codebaseMd, { source: sourceTag, syncedAt }); + toWrite = merged.mergedMd; + } catch (err) { + log.warn(`[section-merge] ${err instanceof Error ? err.message : err};fallback 到全量重写`); + if (oldFile !== null && !dryRun) { + const bakPath = `${repoMdPath}.bak`; + try { await fs.writeFile(bakPath, oldFile, 'utf8'); } catch {} } + merged = mergeWithAnchors(null, codebaseMd, { source: sourceTag, syncedAt }); + toWrite = merged.mergedMd; } - merged = mergeWithAnchors(null, codebaseMd, { source: sourceTag, syncedAt }); - toWrite = merged.mergedMd; - } // 注入 repo_url 到 frontmatter,供 aggregate 映射 domain if (toWrite.startsWith('---\n') && !toWrite.includes('\nrepo_url:')) { @@ -679,6 +665,7 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise } } } + } // end if (codebaseMd) // 4b. 生成 teamwiki/ 知识图谱产物(写入 team-repo 以便自动 push) const teamRepoDir = path.join(process.cwd(), '.teamai', 'team-repo'); diff --git a/src/utils/ai-client.ts b/src/utils/ai-client.ts index 1c95eb8..3d48465 100644 --- a/src/utils/ai-client.ts +++ b/src/utils/ai-client.ts @@ -10,7 +10,7 @@ const ALLOWED_CLI_CANDIDATES = [ const CLI_DETECT_TIMEOUT_MS = 5_000; /** 默认 AI 调用超时时间(毫秒)。仓库初始化等大文档生成场景需要较长时间。 */ -const DEFAULT_TIMEOUT_MS = 720_000; +const DEFAULT_TIMEOUT_MS = 1200_000; /** 默认并发数量上限。 */ const DEFAULT_CONCURRENCY = 3; From b37e0042b66b00eacf227696b52f92f103559c20 Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Thu, 25 Jun 2026 12:09:28 +0800 Subject: [PATCH 13/21] feat(extract): add TOML/SQL/CONF extractor for config repos - code-collector now scans .toml, .sql, .conf, .ini files - New extractors/config.ts: extractToml (section headers + uppercase keys) and extractSql (CREATE TABLE, ALTER TABLE, CREATE INDEX) - Config repos (like hai_api_configs, hai_flow_configs) now produce meaningful config/data nodes in the knowledge graph --other=add config file extraction support --- .../code-knowledge/code-collector.ts | 10 ++- .../code-knowledge/extractors/config.ts | 64 +++++++++++++++++++ .../code-knowledge/extractors/index.ts | 3 + 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 src/wiki-engine/code-knowledge/extractors/config.ts diff --git a/src/wiki-engine/code-knowledge/code-collector.ts b/src/wiki-engine/code-knowledge/code-collector.ts index 70f3bf2..754a020 100644 --- a/src/wiki-engine/code-knowledge/code-collector.ts +++ b/src/wiki-engine/code-knowledge/code-collector.ts @@ -110,7 +110,7 @@ async function walk(directory: string, results: string[], includeTests: boolean) } function isCodeFile(filePath: string): boolean { - return [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".rs", ".java", ".json", ".yaml", ".yml"].includes( + return [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".rs", ".java", ".json", ".yaml", ".yml", ".toml", ".sql", ".conf", ".ini"].includes( path.extname(filePath).toLowerCase() ); } @@ -121,7 +121,13 @@ function isTestPath(filePath: string): boolean { function languageFor(filePath: string): string { const ext = path.extname(filePath).toLowerCase(); - return ({ ".ts": "typescript", ".tsx": "typescript", ".js": "javascript", ".jsx": "javascript", ".py": "python", ".go": "go", ".rs": "rust", ".java": "java", ".json": "json", ".yaml": "yaml", ".yml": "yaml" } as Record)[ext] ?? "text"; + const map: Record = { + ".ts": "typescript", ".tsx": "typescript", ".js": "javascript", ".jsx": "javascript", + ".py": "python", ".go": "go", ".rs": "rust", ".java": "java", + ".json": "json", ".yaml": "yaml", ".yml": "yaml", + ".toml": "toml", ".sql": "sql", ".conf": "toml", ".ini": "toml", + }; + return map[ext] ?? "text"; } async function gitCommit(root: string): Promise { diff --git a/src/wiki-engine/code-knowledge/extractors/config.ts b/src/wiki-engine/code-knowledge/extractors/config.ts new file mode 100644 index 0000000..1d92b1f --- /dev/null +++ b/src/wiki-engine/code-knowledge/extractors/config.ts @@ -0,0 +1,64 @@ +import { type CodeCollectedFile } from "../code-collector.js"; +import { type CodeFact, type CodeFactKind, mapKindToEvidenceType } from "../code-extractors.js"; + +function makeFact(kind: CodeFactKind, name: string, file: string, line: number, detail: string): CodeFact { + return { kind, name, file, lineStart: line, detail, confidence: "EXTRACTED", evidenceType: mapKindToEvidenceType(kind) }; +} + +/** + * Extract config facts from TOML/INI/CONF files. + * Captures section headers and key-value pairs. + */ +export function extractToml(files: CodeCollectedFile[]): CodeFact[] { + const facts: CodeFact[] = []; + for (const file of files) { + const lines = file.content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + // [section] headers + const sectionMatch = line.match(/^\[([^\]]+)\]$/); + if (sectionMatch) { + facts.push(makeFact("config", sectionMatch[1], file.relativePath, i + 1, line)); + continue; + } + // KEY = value (uppercase keys are likely env/config constants) + const kvMatch = line.match(/^([A-Z][A-Z0-9_]{2,})\s*=\s*(.+)/); + if (kvMatch) { + facts.push(makeFact("config", kvMatch[1], file.relativePath, i + 1, line)); + } + } + } + return facts; +} + +/** + * Extract facts from SQL files. + * Captures CREATE TABLE/INDEX, ALTER TABLE, and key INSERT patterns. + */ +export function extractSql(files: CodeCollectedFile[]): CodeFact[] { + const facts: CodeFact[] = []; + for (const file of files) { + const lines = file.content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + // CREATE TABLE + const createTable = line.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"']?(\w+)[`"']?/i); + if (createTable) { + facts.push(makeFact("data", createTable[1], file.relativePath, i + 1, line)); + continue; + } + // ALTER TABLE + const alterTable = line.match(/ALTER\s+TABLE\s+[`"']?(\w+)[`"']?/i); + if (alterTable) { + facts.push(makeFact("data", `alter:${alterTable[1]}`, file.relativePath, i + 1, line)); + continue; + } + // CREATE INDEX + const createIndex = line.match(/CREATE\s+(?:UNIQUE\s+)?INDEX\s+[`"']?(\w+)[`"']?/i); + if (createIndex) { + facts.push(makeFact("data", `index:${createIndex[1]}`, file.relativePath, i + 1, line)); + } + } + } + return facts; +} diff --git a/src/wiki-engine/code-knowledge/extractors/index.ts b/src/wiki-engine/code-knowledge/extractors/index.ts index fd1b890..19c2b17 100644 --- a/src/wiki-engine/code-knowledge/extractors/index.ts +++ b/src/wiki-engine/code-knowledge/extractors/index.ts @@ -1,5 +1,6 @@ import { type CodeCollectedFile } from "../code-collector.js"; import { type CodeFact } from "../code-extractors.js"; +import { extractToml, extractSql } from "./config.js"; import { extractGo } from "./go.js"; import { extractJava } from "./java.js"; import { extractPython } from "./python.js"; @@ -18,6 +19,8 @@ const EXTRACTOR_REGISTRY: Record = { python: extractPython, java: extractJava, rust: extractRust, + toml: extractToml, + sql: extractSql, }; /** From 1c6f9136ee990cc7c1bebf3f2a8af9e56a26372b Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Thu, 25 Jun 2026 12:14:04 +0800 Subject: [PATCH 14/21] feat(cross-repo): add config node matching for cross-repo edges When merging graphs from multiple repos, also check if config/data nodes in one repo match component/interface labels in another. Uses exact label match (lowercased, min 5 chars) to avoid false positives. Note: deeper config-key-to-code-reference detection (e.g. conf.get("key")) requires string literal analysis, left for future enhancement. --other=config cross-repo edge detection --- src/import-repo.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/import-repo.ts b/src/import-repo.ts index 8039cc1..0f1c9b9 100644 --- a/src/import-repo.ts +++ b/src/import-repo.ts @@ -134,6 +134,36 @@ function detectCrossRepoEdges( } } + // 配置仓库关联:config/data 节点的 label 与另一仓库的组件/接口节点 label 完全匹配 + const overlayConfigs = overlay.nodes.filter(n => n.kind === 'config' || n.kind === 'data'); + const existingConfigs = existing.nodes.filter(n => n.kind === 'config' || n.kind === 'data'); + + for (const cfg of overlayConfigs) { + const cfgName = cfg.label.toLowerCase(); + if (cfgName.length < 5) continue; + const match = existingIndex.get(cfgName); + if (match) { + const key = `${match}|${cfg.id}`; + if (!edgeSet.has(key)) { + edgeSet.add(key); + crossEdges.push({ from: match, to: cfg.id, relation: 'DEPENDS_ON' }); + } + } + } + + for (const cfg of existingConfigs) { + const cfgName = cfg.label.toLowerCase(); + if (cfgName.length < 5) continue; + const match = overlayIndex.get(cfgName); + if (match) { + const key = `${match}|${cfg.id}`; + if (!edgeSet.has(key)) { + edgeSet.add(key); + crossEdges.push({ from: match, to: cfg.id, relation: 'DEPENDS_ON' }); + } + } + } + return crossEdges; } From 8a4f822b5ac1b42682d73672313b4dd75b9ff861 Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Thu, 25 Jun 2026 14:18:24 +0800 Subject: [PATCH 15/21] feat(extract): add module summaries and AI overview for better recall quality 1. Module summaries (modules/.md): - Groups facts by top-level directory - Shows dependency direction (depends on / depended by) - Lists top 20 core components ranked by reference count - Only generated for modules with 5+ facts 2. AI overview (overview.md): - When generateCodebaseMd succeeds, its output is saved as evidence//overview.md in teamwiki - Provides "why/how" context that deterministic facts alone lack - Gracefully skipped when AI scan times out --other=improve recall quality with module summaries --- src/codebase-extract.ts | 121 ++++++++++++++++++++++++++++++++++++++++ src/import-repo.ts | 13 +++++ 2 files changed, 134 insertions(+) diff --git a/src/codebase-extract.ts b/src/codebase-extract.ts index 1b7455c..cb3d0a6 100644 --- a/src/codebase-extract.ts +++ b/src/codebase-extract.ts @@ -221,6 +221,117 @@ function buildEvidencePages(facts: CodeFact[], project: string): Map { + const modules = new Map(); + + // 按顶层目录分组(排除 relation facts) + for (const fact of facts) { + if (fact.kind === 'relation') continue; + const parts = fact.file.split('/'); + const module = parts.length > 1 ? parts[0] : '_root'; + const existing = modules.get(module) ?? []; + existing.push(fact); + modules.set(module, existing); + } + + const summaries = new Map(); + + // 只为有 5+ 个 facts 的模块生成摘要 + for (const [module, moduleFacts] of modules) { + if (moduleFacts.length < 5) continue; + + // 统计该模块的引用次数(作为 edge target 的次数) + const fileRefs = new Map(); + for (const edge of graph.edges) { + if (edge.to.startsWith(module + '/') || edge.to === module) { + fileRefs.set(edge.to, (fileRefs.get(edge.to) ?? 0) + 1); + } + } + + // 按 kind 统计 + const kindCounts: Record = {}; + for (const f of moduleFacts) { + kindCounts[f.kind] = (kindCounts[f.kind] ?? 0) + 1; + } + + // 按引用次数排序,取 top 20 核心组件 + const ranked = moduleFacts + .filter(f => f.kind === 'component' || f.kind === 'interface') + .map(f => ({ ...f, refs: fileRefs.get(f.file) ?? 0 })) + .sort((a, b) => b.refs - a.refs) + .slice(0, 20); + + // 该模块依赖的其他模块 + const depsTo = new Set(); + const depsFrom = new Set(); + for (const edge of graph.edges) { + if (edge.from.startsWith(module + '/')) { + const targetMod = edge.to.split('/')[0]; + if (targetMod !== module) depsTo.add(targetMod); + } + if (edge.to.startsWith(module + '/')) { + const sourceMod = edge.from.split('/')[0]; + if (sourceMod !== module) depsFrom.add(sourceMod); + } + } + + const lines = [ + '---', + `title: ${project} — ${module} module`, + 'domain: code-knowledge', + `source: [${module}/]`, + '---', + '', + `# ${module}`, + '', + `**${moduleFacts.length} facts** (${Object.entries(kindCounts).map(([k, v]) => `${k}: ${v}`).join(', ')})`, + '', + ]; + + if (depsTo.size > 0) { + lines.push(`**Depends on**: ${[...depsTo].join(', ')}`); + } + if (depsFrom.size > 0) { + lines.push(`**Depended by**: ${[...depsFrom].join(', ')}`); + } + if (depsTo.size > 0 || depsFrom.size > 0) lines.push(''); + + lines.push('## Core components'); + lines.push(''); + for (const item of ranked) { + const refStr = item.refs > 0 ? ` (${item.refs} refs)` : ''; + lines.push(`- \`${item.name}\` ← ${item.file}:${item.lineStart}${refStr}`); + } + + if (moduleFacts.some(f => f.kind === 'config')) { + lines.push(''); + lines.push('## Config'); + lines.push(''); + for (const f of moduleFacts.filter(f => f.kind === 'config').slice(0, 10)) { + lines.push(`- \`${f.name}\` ← ${f.file}`); + } + } + + if (moduleFacts.some(f => f.kind === 'error')) { + lines.push(''); + lines.push('## Errors'); + lines.push(''); + for (const f of moduleFacts.filter(f => f.kind === 'error').slice(0, 10)) { + lines.push(`- \`${f.name}\` ← ${f.file}`); + } + } + + lines.push(''); + summaries.set(`${module}.md`, lines.join('\n')); + } + + return summaries; +} + export async function extractCodebase(opts: ExtractCodebaseOptions): Promise { const root = path.resolve(opts.path || '.'); const project = opts.project || path.basename(root); @@ -282,6 +393,16 @@ export async function extractCodebase(opts: ExtractCodebaseOptions): Promise 0) { + const modulesDir = path.join(evidenceDir, 'modules'); + await mkdir(modulesDir, { recursive: true }); + for (const [filename, content] of moduleSummaries) { + await writeFile(path.join(modulesDir, filename), content, 'utf-8'); + } + } + // 生成 team-wiki 标准入口文件 const proj = [{ slug: project, label: project }]; await writeFile(path.join(wikiRoot, 'router.md'), routerTemplate(proj), 'utf-8'); diff --git a/src/import-repo.ts b/src/import-repo.ts index 0f1c9b9..0622f91 100644 --- a/src/import-repo.ts +++ b/src/import-repo.ts @@ -712,6 +712,19 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise const evidenceDest = path.join(teamwikiRoot, 'evidence', 'code', slug); await fs.ensureDir(evidenceDest); await fs.copy(evidenceSrc, evidenceDest, { overwrite: true }); + // 如果 AI 扫描成功,将架构概述写入 overview.md + if (codebaseMd) { + const overviewContent = [ + '---', + `title: ${slug} overview`, + 'domain: code-knowledge', + `source: [${url}]`, + '---', + '', + codebaseMd.replace(/^---[\s\S]*?---\n*/m, ''), + ].join('\n'); + await fs.writeFile(path.join(evidenceDest, 'overview.md'), overviewContent, 'utf8'); + } // 合并 graph-index const srcGraph = path.join(cacheWiki, '.indices', 'graph-index.json'); const destGraph = path.join(teamwikiRoot, '.indices', 'graph-index.json'); From 7339b3f9cc5b4c4b06f2de5c88e16c1b727ad317 Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Thu, 25 Jun 2026 14:21:29 +0800 Subject: [PATCH 16/21] feat(recall-agent): update prompt for teamwiki knowledge graph awareness - Step 1: check teamwiki/router.md + index.md for codebase context - Step 4: prefer module summaries over raw facts; use overview.md for architectural context; relay knowledge gaps - Add 'codebase' as a valid type tag - Increase output cap to 2500 chars (module summaries are richer) - Add rule: prefer module summaries, mention gaps --other=recall subagent prompt enhancement --- agents/teamai-recall.md | 51 +++++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/agents/teamai-recall.md b/agents/teamai-recall.md index 5cf7d6d..1f7a5a8 100644 --- a/agents/teamai-recall.md +++ b/agents/teamai-recall.md @@ -1,6 +1,6 @@ --- name: teamai-recall -description: Search the team knowledge base (skills + learnings + docs + rules) and return a compact, structured summary with doc_ids — instead of dumping full knowledge content into the main conversation. Invoke this BEFORE any task involving code changes, troubleshooting, or design. +description: Search the team knowledge base (skills + learnings + docs + rules + codebase graph) and return a compact, structured summary with doc_ids — instead of dumping full knowledge content into the main conversation. Invoke this BEFORE any task involving code changes, troubleshooting, or design. tools: Bash, Read, Grep, Glob --- @@ -20,16 +20,23 @@ upstream API"). Treat this as your query. ## What you must do — step by step -### Step 1 — Read the codebase manifest (optional but preferred) +### Step 1 — Read codebase context (optional but preferred) -If `~/.teamai/docs/codebase.md` OR `docs/team-codebase/index.md` (in the -current project) exists, read it first. It lists the team's repositories -and their purposes. Extract a one-sentence repo-list summary to prepend to -your final output. If neither file exists, **silently skip** this step — -never error out. +Check for the team's code knowledge graph in this order: -> Note: `teamai recall` already indexes team-codebase documents -> (repos/*.md), so Step 3 will return codebase knowledge matches directly. +1. `teamwiki/router.md` — if exists, read it to understand available repos +2. `teamwiki/index.md` — global navigation with domain links + +If `teamwiki/` exists, the team has a structured knowledge graph. After +Step 3 returns codebase hits, you can **drill into** module summaries: +- `teamwiki/evidence/code//modules/.md` — module-level overview with dependency direction and top components +- `teamwiki/evidence/code//overview.md` — AI-generated architecture context (why/how, not just what) + +Fallback: if no `teamwiki/`, check `~/.teamai/docs/codebase.md` or +`docs/team-codebase/index.md`. If none exists, silently skip. + +> `teamai recall` automatically searches both flat knowledge (learnings/ +> skills/docs/rules) and codebase graph (teamwiki/) with BM25 + graph-boost. ### Step 2 — Extract keywords from the task description @@ -51,12 +58,21 @@ If the command fails, knowledge base is empty, or returns zero hits, emit a single line `No relevant team knowledge found for: ` and stop. -### Step 4 — Read the top hits +### Step 4 — Read the top hits and drill into codebase For each hit returned by `teamai recall`, read the source file directly -(use `Read`) and condense each into **one or two sentences**. Cap your -total summary at ~1500 characters. Drop hits that on closer inspection -are clearly off-topic. +(use `Read`) and condense each into **one or two sentences**. + +**For codebase hits** (path contains `teamwiki/evidence/`): +- If the hit is a raw facts page (component.md, interface.md), prefer + reading the corresponding **module summary** (`modules/.md`) instead — + it's more concise and shows dependencies. +- If you need architectural context (why a module exists, design decisions), + check `overview.md` in the same project directory. +- If the hit mentions a knowledge gap (from `gaps/detected.md`), relay + it to the user: "This area is not fully documented in the knowledge base." + +Cap your total summary at ~2000 characters. Drop hits that are off-topic. ### Step 5 — Emit a structured response @@ -81,8 +97,9 @@ Return your output in **this exact format** to the main conversation: ``` Where: -- `` is one of `skills` / `learnings` / `docs` / `rules` +- `` is one of `skills` / `learnings` / `docs` / `rules` / `codebase` - `` is the filename without extension (e.g. `api-timeout-fix`) + For codebase hits, use the relative path within teamwiki/ (e.g. `evidence/code/hai_api/modules/business`) - The trailing HTML comment **must** list every doc_id you returned — later phases (Phase 3 Stop hook) will parse this from the conversation transcript. @@ -93,5 +110,9 @@ Where: - **Do not** call `teamai recall` more than 3 times in one invocation. - **Do not** invoke other subagents. - If `teamai` CLI is not on PATH, return `teamai CLI not available` and stop. -- Output total ≤ ~2000 characters. The whole point of using a subagent is +- Output total ≤ ~2500 characters. The whole point of using a subagent is to keep the main conversation's context lean. +- For codebase hits, **prefer module summaries over raw facts pages** — + they give better signal-to-noise for the main conversation. +- If `teamwiki/gaps/detected.md` exists and is relevant, mention the gap + so the main conversation does not hallucinate answers for undocumented areas. From 079312b103092c20cafe6a765e7496c70b7345ad Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Thu, 25 Jun 2026 14:25:11 +0800 Subject: [PATCH 17/21] feat(recall-agent): structured output with inline codebase context Output now includes three sections: - Relevant knowledge: standard doc_id + summary entries - Codebase context: module dependency direction + top 5 core components extracted inline from module summaries (no second Read needed) - Gaps: explicit warning when query hits undocumented areas This eliminates the main conversation's need for follow-up retrieval when codebase knowledge is relevant. --other=recall subagent output structure enhancement --- agents/teamai-recall.md | 40 +++++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/agents/teamai-recall.md b/agents/teamai-recall.md index 1f7a5a8..0ee5f3b 100644 --- a/agents/teamai-recall.md +++ b/agents/teamai-recall.md @@ -81,25 +81,43 @@ Return your output in **this exact format** to the main conversation: ``` ## Team Knowledge Recall -> Repos: +> Repos: + +### Relevant knowledge 1. **[] ** — Confidence: -2. **[] ** — - - Confidence: +2. ... + +### Codebase context (if any codebase hits) -... +**Module: ** () +- Depends on: +- Depended by: +- Core components: `Foo`, `Bar`, `Baz` (top 5 by reference count) +- Architecture: + +### Gaps (if relevant) + +⚠️ — do not guess answers for this area. ``` -Where: +**Output structure rules:** + - `` is one of `skills` / `learnings` / `docs` / `rules` / `codebase` -- `` is the filename without extension (e.g. `api-timeout-fix`) +- `` is the filename without extension (e.g. `api-timeout-fix`). For codebase hits, use the relative path within teamwiki/ (e.g. `evidence/code/hai_api/modules/business`) +- **Codebase context section**: when a codebase hit is returned, include + the module's dependency direction and top 5 components **inline** — the + main conversation should not need a second Read to understand the module. + Extract this from `modules/.md` which you already read in Step 4. +- **Gaps section**: only include if `gaps/detected.md` was relevant to the + query. This tells the main conversation to stop and ask the user rather + than hallucinating. - The trailing HTML comment **must** list every doc_id you returned — later phases (Phase 3 Stop hook) will parse this from the conversation transcript. @@ -114,5 +132,9 @@ Where: to keep the main conversation's context lean. - For codebase hits, **prefer module summaries over raw facts pages** — they give better signal-to-noise for the main conversation. -- If `teamwiki/gaps/detected.md` exists and is relevant, mention the gap - so the main conversation does not hallucinate answers for undocumented areas. +- **Include module dependency + core components inline** so the main + conversation can act without a second retrieval round-trip. +- If `teamwiki/gaps/detected.md` exists and is relevant, include the + Gaps section so the main conversation does not hallucinate. +- When zero hits are found but `teamwiki/` exists, check if the query + relates to a known gap before returning "no knowledge found". From 526b00edbbe503be8d8225663cb64cdf09f4d701 Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Thu, 25 Jun 2026 16:40:49 +0800 Subject: [PATCH 18/21] fix(contribute-check): fix hint never reaching user + scoring adjustments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 (critical): Stop hook output used invalid {"stopReason":...} format. Claude Code only passes hookSpecificOutput.additionalContext to the AI. 5 sessions had hinted=true but users never saw the hint. Fix: new src/utils/hook-output.ts with multi-tool format awareness (Claude Code/CodeBuddy use hookSpecificOutput, Cursor uses {message}). Bug 2 (minor): scoring formula adjustments: - Tool count start: 30→20 - Duration: add 15min tier (+10) - Git commit penalty: -15→0 (neutral) - BASE_THRESHOLD: 20→15 --- src/__tests__/contribute-check-phase2.test.ts | 2 +- src/contribute-check.ts | 26 +++++++++--------- src/hook-handlers.ts | 7 +++-- src/types.ts | 6 ++--- src/utils/hook-output.ts | 27 +++++++++++++++++++ 5 files changed, 48 insertions(+), 20 deletions(-) create mode 100644 src/utils/hook-output.ts diff --git a/src/__tests__/contribute-check-phase2.test.ts b/src/__tests__/contribute-check-phase2.test.ts index 5e3c79f..c7e5297 100644 --- a/src/__tests__/contribute-check-phase2.test.ts +++ b/src/__tests__/contribute-check-phase2.test.ts @@ -128,7 +128,7 @@ describe('applyPhase2Adjustments', () => { const gitRepo = path.resolve(__dirname, '../../'); const veryOldStart = '2020-01-01T00:00:00Z'; const result = applyPhase2Adjustments(5, sessionId, gitRepo, veryOldStart); - expect(result.score).toBe(0); + expect(result.score).toBe(5); }); }); diff --git a/src/contribute-check.ts b/src/contribute-check.ts index 20b1fb2..665eb52 100644 --- a/src/contribute-check.ts +++ b/src/contribute-check.ts @@ -201,35 +201,37 @@ export function computeSmartScore(events: DashboardEvent[]): number { let score = 0; - // Tool count — gradient (max 20 points) - // 30+ calls → 10, scales linearly up to 80+ → 20 - if (totalToolCalls >= 30) { - score += Math.min(20, Math.round(((totalToolCalls - 30) / 50) * 10) + 10); + // Tool count — gradient (max 25 points) + // 20+ calls → 5, scales linearly up to 80+ → 25 + if (totalToolCalls >= 20) { + score += Math.min(25, Math.round(((totalToolCalls - 20) / 60) * 20) + 5); } - // Tool diversity (max 30 points) + // Tool diversity (max 20 points) if (totalToolCalls > 0) { - const diversity = toolNames.size / Math.min(totalToolCalls, 20); // Cap denominator at 20 - score += Math.min(Math.round(diversity * 30), 30); + const diversity = toolNames.size / Math.min(totalToolCalls, 10); + score += Math.min(Math.round(diversity * 20), 20); } - // Skill usage (15 points) + // Skill usage (10 points) if (hasSkills) { - score += 15; + score += 10; } - // Error indicators (15 points) + // Error indicators (10 points) if (hasErrors) { - score += 15; + score += 10; } - // Session duration (20 points if > 30 min) + // Session duration (max 20 points) if (events.length >= 2) { const first = new Date(events[0].timestamp).getTime(); const last = new Date(events[events.length - 1].timestamp).getTime(); const durationMin = (last - first) / (1000 * 60); if (durationMin > 30) { score += 20; + } else if (durationMin > 15) { + score += 10; } } diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 7b49743..ffb3c12 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -147,18 +147,17 @@ const trackSlashHandler: HookHandler = { const contributeCheckHandler: HookHandler = { name: 'contribute-check', - async execute(stdin, _tool) { + async execute(stdin, tool) { const { contributeCheckForSession } = await import('./contribute-check.js'); + const { formatStopHookOutput } = await import('./utils/hook-output.js'); - // Derive session ID from STDIN const sessionId = typeof stdin.session_id === 'string' ? stdin.session_id : null; if (!sessionId) return null; const cwd = typeof stdin.cwd === 'string' ? stdin.cwd : undefined; const { hint } = await contributeCheckForSession(sessionId, cwd); if (hint) { - // Stop event format: { stopReason: "..." } - return JSON.stringify({ stopReason: hint }); + return formatStopHookOutput(hint, tool); } return null; }, diff --git a/src/types.ts b/src/types.ts index b496515..a154513 100644 --- a/src/types.ts +++ b/src/types.ts @@ -411,7 +411,7 @@ export interface ContributeState { } /** Layer 1 (fast-path) threshold: if toolCount < this, skip reading events.jsonl */ -export const CONTRIBUTE_BASE_THRESHOLD = 20; +export const CONTRIBUTE_BASE_THRESHOLD = 15; /** Smart score threshold: minimum score to show contribute hint */ export const CONTRIBUTE_SMART_THRESHOLD = 35; @@ -428,8 +428,8 @@ export const CONTRIBUTE_LOW_QUALITY_BONUS = 10; /** Phase 2: threshold below which recall results are considered low quality */ export const CONTRIBUTE_LOW_QUALITY_THRESHOLD = 5.0; -/** Phase 2: score deduction when session has git commits and recall had hits */ -export const CONTRIBUTE_GIT_COMMIT_DOWNWEIGHT = 15; +/** Phase 2: git commit is neutral (no bonus, no penalty) */ +export const CONTRIBUTE_GIT_COMMIT_DOWNWEIGHT = 0; /** Directory for per-session contribute state files */ export const CONTRIBUTE_SESSIONS_DIR = `${TEAMAI_HOME}/sessions`; diff --git a/src/utils/hook-output.ts b/src/utils/hook-output.ts new file mode 100644 index 0000000..e30791a --- /dev/null +++ b/src/utils/hook-output.ts @@ -0,0 +1,27 @@ +/** + * Multi-tool-aware hook output formatting. + * + * Different AI tools parse Stop hook STDOUT differently: + * - Claude Code / CodeBuddy: hookSpecificOutput.additionalContext → visible to AI + * - Cursor: direct JSON message → shown in UI + * - Codex etc.: default hookSpecificOutput (maximum compatibility) + */ + +/** + * Format Stop hook output so the AI can see the hint content. + * + * @param message Hint text to pass to the AI + * @param tool Current AI tool identifier (claude / cursor / codebuddy / codex / etc.) + * @returns JSON string to write to STDOUT + */ +export function formatStopHookOutput(message: string, tool: string): string { + if (tool === 'cursor') { + return JSON.stringify({ message }); + } + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'Stop', + additionalContext: message, + }, + }); +} From 55bf5ebd6bd97677046a59fa6247feba5d157e4e Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Thu, 25 Jun 2026 16:57:28 +0800 Subject: [PATCH 19/21] docs: add teamwiki/ directory structure to README (EN + CN) Document the codebase knowledge graph output format with real data from HAI team (11 repos, 2218 nodes, 852 edges). Explains each file's purpose: router.md, graph-index.json, evidence pages, relation pages, and gaps detection. --- README.md | 36 ++++++++++++++++++++++++++++++++++++ README.zh-CN.md | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/README.md b/README.md index f6ff624..03835f7 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,42 @@ Author: alice | Score: 12.0 | Tags: fuse, deploy The index is rebuilt automatically on every `teamai pull`. Indexes built by older versions (no `version` field or missing `type`) are detected and rebuilt transparently on first use. +### Codebase Knowledge Graph (teamwiki/) + +`teamai codebase --extract` (or `teamai import --from-repo`) parses your source repos and writes a structured knowledge graph under `teamwiki/`: + +``` +teamwiki/ +├── router.md # Navigation hub — lists every imported repo +├── index.md # Global index (auto-generated, with timestamp) +├── hot.md # Active working memory (reserved for Phase 4) +├── source-manifest.json # Per-file hash manifest for incremental extraction +├── .indices/ +│ └── graph-index.json # Knowledge graph: nodes + edges (JSON) +├── evidence/ +│ └── code/ +│ └── / # One directory per imported repo +│ ├── index.md # Project summary (fact count + page list) +│ ├── component.md # Functions / classes / components +│ ├── interface.md # Interface and type definitions +│ ├── config.md # Config keys (env vars, TOML keys, etc.) +│ ├── error.md # Error-handling patterns +│ └── relation-.md # Import relationships grouped by top-level dir +└── gaps/ + └── detected.md # Detected knowledge gaps (IMPL_MISSING, LOW_CONNECTIVITY, …) +``` + +**graph-index.json** stores the extracted graph. A real example: 11 HAI team repos → **2 218 nodes, 852 edges**. + +| Field | Description | +|-------|-------------| +| `nodes[].kind` | `component` (function/class) or `config` (config key) | +| `edges[].relation` | `imports` — cross-file and cross-repo dependency | + +Cross-repo edges are detected automatically by PascalCase label matching. + +`teamai recall` uses this graph for **BM25 + graph-boost** retrieval: keyword hits are re-ranked by graph proximity, so you get structurally relevant results, not just textual matches. + ### TodoWrite reminder hook `teamai pull` registers a PostToolUse hook on the `TodoWrite` tool. The first time a session writes a TODO list, the hook injects a one-time reminder asking the agent to invoke `teamai-recall` if it has not already done so. Per-session deduplication uses `~/.teamai/sessions/-todowrite-hint.json` (24 h TTL). diff --git a/README.zh-CN.md b/README.zh-CN.md index cc5cdfb..2bab71b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -317,6 +317,42 @@ Author: alice | Score: 12.0 | Tags: fuse, deploy 索引在每次 `teamai pull` 时自动重建。旧版索引(无 `version` 字段或缺少 `type`)会在首次使用时被自动检测并重建,对调用方透明 +### 代码库知识图谱(teamwiki/) + +`teamai codebase --extract`(或 `teamai import --from-repo`)解析源码仓库,将结构化知识图谱写入 `teamwiki/` 目录: + +``` +teamwiki/ +├── router.md # 导航枢纽,列出所有已导入仓库 +├── index.md # 全局索引(自动生成,含时间戳) +├── hot.md # 活跃工作记忆(Phase 4 hot/cold 预留) +├── source-manifest.json # 源文件哈希清单(增量提取用) +├── .indices/ +│ └── graph-index.json # 知识图谱:nodes + edges(JSON 格式) +├── evidence/ +│ └── code/ +│ └── / # 每个导入的仓库一个目录 +│ ├── index.md # 项目摘要(facts 总数 + 页面列表) +│ ├── component.md # 函数 / 类 / 组件 +│ ├── interface.md # 接口和类型定义 +│ ├── config.md # 配置项(环境变量、TOML key 等) +│ ├── error.md # 错误处理模式 +│ └── relation-.md # 按顶级目录分组的 import 依赖关系 +└── gaps/ + └── detected.md # 知识缺口检测结果(IMPL_MISSING / LOW_CONNECTIVITY / …) +``` + +**graph-index.json** 存储提取出的知识图谱。真实数据参考:HAI 团队 11 个仓库 → **2 218 个节点,852 条边**。 + +| 字段 | 说明 | +|------|------| +| `nodes[].kind` | `component`(函数/类)或 `config`(配置项) | +| `edges[].relation` | `imports` —— 跨文件或跨仓库依赖关系 | + +跨仓 edge 通过 PascalCase 标签匹配自动检测,无需手动配置。 + +`teamai recall` 利用此图谱进行 **BM25 + graph-boost** 检索:关键词命中后按图结构邻近度重排序,结果兼具文本相关性和结构相关性。 + ### TodoWrite 提醒 hook `teamai pull` 会在 `TodoWrite` 工具上注册一个 PostToolUse hook。当 session 第一次写 TODO 列表时,hook 会注入一次性提醒,要求 agent 在尚未调用 `teamai-recall` 时先调用一次。session 级去重通过 `~/.teamai/sessions/-todowrite-hint.json` 实现(TTL 24 小时) From 161fa15fff298bfe1e4d4623e6f74e9dcefb1fd2 Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Thu, 25 Jun 2026 17:15:13 +0800 Subject: [PATCH 20/21] refactor(cli): hide internal hook commands and simplify help output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hide 10 hook-only commands from --help (hook-dispatch, track, auto-recall, contribute-check, etc.) — still functional, just hidden - Hide domains command (replaced by knowledge graph edges) - Simplify import options: show 8 core options, hide 17 advanced ones - Simplify codebase options: show --lint/--fix/--json, hide rest - Streamline README command table: core commands visible, rest in collapsible section (EN + CN) --- README.md | 66 ++++++++++++++++++++------------------ README.zh-CN.md | 60 +++++++++++++++++++---------------- src/index.ts | 84 +++++++++++++++++++++++++++---------------------- 3 files changed, 116 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 03835f7..1d8541d 100644 --- a/README.md +++ b/README.md @@ -71,36 +71,42 @@ The CLI picks a provider automatically from the repo URL: | Command | Description | |---------|-------------| -| `teamai init [--scope ] [--role ] [--force]` | Initialize (auto-installs gf CLI, OAuth login, links repo, registers member, configures reviewers, injects hooks) | -| `teamai push [--all] [--role ]` | Push local new resources to a dedicated branch and open a Merge Request; new skills prompt interactively for a target namespace (override with `--role`) | -| `teamai pull [--silent]` | Pull team resources and inject them into local AI tools (both scopes pulled sequentially) | -| `teamai status` | Show the diff between local and the team repo | -| `teamai list [type] [--source repo\|local\|all] [--agent ]` | List resources (skills\|rules\|docs\|env\|wiki). With `--source local` or `all`, scans skills directories of installed AI agents and tags each skill's origin (`[team]` / `[builtin]` / `[source:]` / `[local-only]`) | -| `teamai skill [list\|show ]` | List all skills by default; `show ` prints the skill's origin, contributors, installed-agent list, and description summary | -| `teamai members` | List registered team members | -| `teamai remove ` | Remove a resource from both the team repo and local, then open an MR (skills\|rules\|wiki) | -| `teamai roles` | Manage team roles (`init`/`list`/`set`/`add`/`remove`/`update`) | -| `teamai source` | Manage cross-team skill subscription sources (`add`/`remove`/`list`/`browse`) | -| `teamai contribute --file [--scope ]` | Push an AI-generated experience document to the team repo | -| `teamai recall [--depth route\|context\|lookup]` | Search the team knowledge base (learnings + skills + docs + rules + codebase graph). Codebase results use BM25 + graph-neighbor boosting | -| `teamai import --from-repo ` | Clone a remote repo, build a code knowledge graph (`teamwiki/`), and auto-push to team repo. Extracts components, interfaces, configs, errors, and import relations | -| `teamai import --from-repo-list ` | Batch import repos from a whitelist with concurrency control; cross-repo dependency edges auto-detected | -| `teamai import --from-org ` | List every repo under an organization (GitHub or TGit), AI-cluster into domains, then batch import with knowledge graph construction | -| `teamai import --from-iwiki ` | Import iWiki documents as learnings; auto-reconcile MAPS_TO edges between doc terms and code knowledge graph nodes | -| `teamai codebase --extract [path]` | Deterministic code fact extraction (TS/Python/Go/Rust/Java) → `teamwiki/` with evidence pages + graph-index.json + knowledge gaps | -| `teamai codebase --lint` | Knowledge graph health check: node connectivity, stale manifest, navigation files, orphan detection | -| `teamai codebase --upgrade-wiki` | Migrate from old `docs/team-codebase/` format to the new `teamwiki/` knowledge graph | -| `teamai cache --status \| --gc` | Inspect or garbage-collect the shallow-clone cache at `~/.teamai/cache/repos/` (LRU + size cap, default 5GB) | -| `teamai review [id] [--apply \| --reject \| --all-apply]` | Inspect and process pending codebase changes from `.teamai/pending-review.jsonl` | -| `teamai digest` | Generate a team AI usage weekly digest (skill leaderboard, new/updated skills, session summaries) | -| `teamai hooks` | Manage AI-tool hooks (list / inject / remove) | -| `teamai ci extract-mr --url [--mode comment\|write\|both] [--individual-comments]` | CI pipeline: extract learning + graph changes from MR/PR, post as comments (with reaction/reject), write to team repo after merge | -| `teamai uninstall [--force]` | Uninstall teamai: remove hooks, rules, skills, env, docs, and `~/.teamai/` | -| `teamai doctor` | Diagnose configuration problems | - -Global options: -- `--dry-run` — preview mode, no real changes -- `--verbose, -v` — verbose output +| `teamai init` | Initialize (OAuth login, link repo, register member, inject hooks) | +| `teamai push` | Push local resources to a branch and open a Merge Request | +| `teamai pull` | Pull team resources and inject into local AI tools | +| `teamai status` | Show local vs team repo diff | +| `teamai recall ` | Search the team knowledge base (BM25 + graph-boost) | +| `teamai import --from-repo ` | Import a repo's code knowledge graph (`teamwiki/`) | +| `teamai import --from-org ` | Batch import all repos under an organization | +| `teamai import --from-repo-list ` | Batch import repos from a whitelist | +| `teamai import --from-mr ` | Extract learning from a merged MR/PR | +| `teamai import --from-iwiki ` | Import iWiki documents as learnings | +| `teamai codebase --lint` | Knowledge graph health check | +| `teamai contribute` | Share session experience to team repo | +| `teamai doctor` | Diagnose configuration issues | +| `teamai uninstall` | Remove all teamai resources and hooks | + +Global options: `--dry-run`, `--verbose` + +
+More commands (management, CI, analytics) + +| Command | Description | +|---------|-------------| +| `teamai list [type]` | List resources (skills\|rules\|docs\|env\|wiki) | +| `teamai skill [show ]` | Inspect skill metadata and contributors | +| `teamai members` | List team members | +| `teamai remove ` | Remove a resource and open MR | +| `teamai roles` | Manage team roles and namespaces | +| `teamai source` | Manage cross-team skill subscriptions | +| `teamai tags` | Manage tag-based resource filtering | +| `teamai env` | Manage team environment variables | +| `teamai hooks` | Manage AI-tool hooks | +| `teamai cache --gc` | Garbage-collect clone cache | +| `teamai digest` | Generate weekly team usage digest | +| `teamai ci extract-mr --url ` | CI: extract knowledge from MR, post comments, write after merge | + +
## How It Works diff --git a/README.zh-CN.md b/README.zh-CN.md index 2bab71b..5fd4af7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -71,36 +71,42 @@ CLI 会根据用户传入的 repo URL 自动选择 provider: | 命令 | 说明 | |------|------| -| `teamai init [--scope ] [--role ] [--force]` | 初始化(自动安装 gf CLI、OAuth 登录、关联仓库、注册成员、配置 reviewers、注入 hooks) | -| `teamai push [--all] [--role ]` | 推送本地新资源到独立分支并创建 Merge Request;新 skill 交互式选择目标命名空间,可用 `--role` 覆盖 | -| `teamai pull [--silent]` | 拉取团队资源并注入到本地 AI 工具(支持双 scope 依次拉取) | +| `teamai init` | 初始化(OAuth 登录、关联仓库、注册成员、注入 hooks) | +| `teamai push` | 推送本地资源到独立分支并创建 MR | +| `teamai pull` | 拉取团队资源并注入到本地 AI 工具 | | `teamai status` | 查看本地 vs 团队仓库差异 | -| `teamai list [type] [--source repo\|local\|all] [--agent ]` | 列出资源(skills\|rules\|docs\|env\|wiki);`--source local` 或 `all` 时会扫描已安装 AI agent 下的 skills 目录,并标注每个 skill 的来源 (`[team]` / `[builtin]` / `[source:]` / `[local-only]`) | -| `teamai skill [list\|show ]` | 默认列出全部 skill;`show ` 输出指定 skill 的来源、贡献者、已安装的 agent 列表与描述摘要 | -| `teamai members` | 列出已注册的团队成员 | -| `teamai remove ` | 从团队仓库和本地删除资源并创建 MR(skills\|rules\|wiki) | -| `teamai roles` | 管理团队角色(`init`/`list`/`set`/`add`/`remove`/`update`) | -| `teamai source` | 管理跨团队 skill 订阅源(`add`/`remove`/`list`/`browse`) | -| `teamai contribute --file [--scope ]` | 将 AI 生成的经验文档推送到团队仓库 | -| `teamai recall [--depth route\|context\|lookup]` | 搜索团队知识库(learnings + skills + docs + rules + codebase 图谱)。代码知识使用 BM25 + 图谱邻居加权检索 | -| `teamai import --from-repo ` | 拉取远端仓库,构建代码知识图谱(`teamwiki/`),自动推送到团队仓库。提取组件、接口、配置、错误类型和 import 依赖关系 | -| `teamai import --from-repo-list ` | 按白名单批量导入多个仓库(支持并发);自动检测跨仓依赖边 | -| `teamai import --from-org ` | 列出组织/group 下所有仓库(GitHub / TGit),AI 聚类为业务域,批量构建知识图谱 | -| `teamai import --from-iwiki ` | 把 iWiki 文档导入为 learnings;自动与代码知识图谱建立 MAPS_TO 映射关系 | -| `teamai codebase --extract [path]` | 确定性代码知识提取(TS/Python/Go/Rust/Java)→ `teamwiki/` 产物:evidence 页面 + graph-index.json + 知识缺口检测 | -| `teamai codebase --lint` | 知识图谱健康度检查:节点连通性、manifest 过期、导航文件完整性、孤立节点 | -| `teamai codebase --upgrade-wiki` | 从旧 `docs/team-codebase/` 格式迁移到新 `teamwiki/` 知识图谱 | -| `teamai cache --status \| --gc` | 查看或回收 shallow-clone 缓存目录 `~/.teamai/cache/repos/`(LRU + 容量上限,默认 5GB) | -| `teamai review [id] [--apply \| --reject \| --all-apply]` | 浏览并处理 `.teamai/pending-review.jsonl` 中的待审变更 | -| `teamai digest` | 生成团队 AI 使用周报(skill 排行、新增/更新 skill、session 摘要) | -| `teamai hooks` | 管理 AI 工具 hooks(list / inject / remove) | -| `teamai ci extract-mr --url [--mode comment\|write\|both] [--individual-comments]` | CI 流水线:从 MR/PR 提取 learning + 图谱变更,发布评论(支持 reaction/reject),合并后写入团队仓库 | -| `teamai uninstall [--force]` | 卸载 teamai:移除 hooks、rules、skills、env、docs、~/.teamai/ | +| `teamai recall ` | 搜索团队知识库(BM25 + 图谱加权) | +| `teamai import --from-repo ` | 导入仓库代码知识图谱(`teamwiki/`) | +| `teamai import --from-org ` | 批量导入组织下所有仓库 | +| `teamai import --from-repo-list ` | 按白名单批量导入 | +| `teamai import --from-mr ` | 从已合并 MR 提取 learning | +| `teamai import --from-iwiki ` | 从 iWiki 导入文档为 learnings | +| `teamai codebase --lint` | 知识图谱健康度检查 | +| `teamai contribute` | 分享本次 session 经验到团队仓库 | | `teamai doctor` | 诊断配置问题 | +| `teamai uninstall` | 卸载所有 teamai 资源和 hooks | -全局选项: -- `--dry-run` — 预览模式,不做实际变更 -- `--verbose, -v` — 详细输出 +全局选项:`--dry-run`、`--verbose` + +
+更多命令(管理、CI、分析) + +| 命令 | 说明 | +|------|------| +| `teamai list [type]` | 列出资源(skills\|rules\|docs\|env\|wiki) | +| `teamai skill [show ]` | 查看 skill 元数据和贡献者 | +| `teamai members` | 列出团队成员 | +| `teamai remove ` | 删除资源并创建 MR | +| `teamai roles` | 管理团队角色和命名空间 | +| `teamai source` | 管理跨团队 skill 订阅 | +| `teamai tags` | 管理基于标签的资源过滤 | +| `teamai env` | 管理团队环境变量 | +| `teamai hooks` | 管理 AI 工具 hooks | +| `teamai cache --gc` | 回收 clone 缓存 | +| `teamai digest` | 生成团队使用周报 | +| `teamai ci extract-mr --url ` | CI:从 MR 提取知识,发布评论,合并后写入团队仓库 | + +
## 工作原理 diff --git a/src/index.ts b/src/index.ts index 8fe9b8c..e8391d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { createRequire } from 'node:module'; -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import { setVerbose, setSilent, log } from './utils/logger.js'; import type { GlobalOptions } from './types.js'; @@ -431,7 +431,8 @@ hooksCmd // ─── Usage tracking commands ──────────────────────────── program - .command('track [toolName] [toolInput]') + .command('track [toolName] [toolInput]', { hidden: true }) + .description('Track a tool usage event (called by PostToolUse hook)') .option('--stdin', 'Read hook data from STDIN (Claude Code hook format)') .option('--tool ', 'Tool identifier for usage attribution (e.g. claude, claude-internal)') @@ -446,7 +447,8 @@ program }); program - .command('track-slash') + .command('track-slash', { hidden: true }) + .description('Track a slash command usage (called by UserPromptSubmit hook)') .option('--stdin', 'Read hook data from STDIN') .option('--tool ', 'Tool identifier for usage attribution (e.g. claude, claude-internal)') @@ -466,7 +468,8 @@ program }); program - .command('save-session') + .command('save-session', { hidden: true }) + .description('Save current session tool usage summary') .option('--summary ', 'Session summary text') .action(async (cmdOpts) => { @@ -495,7 +498,8 @@ program }); program - .command('dashboard-report') + .command('dashboard-report', { hidden: true }) + .description('Report session state to dashboard (called by hooks)') .option('--stdin', 'Read hook data from STDIN') .option('--tool ', 'Tool identifier (e.g. claude, claude-internal)') @@ -509,7 +513,8 @@ program // ─── Contribute commands ────────────────────────────────── program - .command('contribute-check') + .command('contribute-check', { hidden: true }) + .description('Check if session qualifies for contribution (called by PostToolUse hook)') .option('--stdin', 'Read hook data from STDIN') .option('--tool ', 'Tool identifier (e.g. claude, claude-internal)') @@ -547,7 +552,8 @@ program }); program - .command('auto-recall') + .command('auto-recall', { hidden: true }) + .description('Auto-recall team knowledge on tool errors (called by PostToolUse hook)') .option('--stdin', 'Read hook data from STDIN') .action(async (cmdOpts) => { @@ -558,7 +564,8 @@ program }); program - .command('todowrite-hint') + .command('todowrite-hint', { hidden: true }) + .description('Remind the agent to invoke teamai-recall when TodoWrite is used (PostToolUse hook)') .option('--stdin', 'Read hook data from STDIN') .option('--tool ', 'Source AI tool (claude / codebuddy / cursor)') @@ -573,30 +580,30 @@ program .command('import') .description('Import knowledge from local files, Claude/Cursor rules, git workspace, MRs, or iWiki') .option('--dir ', 'Scan local directory for importable Markdown files') - .option('--from-claude', 'Scan Claude/Cursor rule directories (~/.claude/rules, ~/.cursor/rules)') - .option('--workspace', 'Generate codebase.md from current git workspace') + .addOption(new Option('--from-claude', 'Scan Claude/Cursor rule directories (~/.claude/rules, ~/.cursor/rules)').hideHelp()) + .addOption(new Option('--workspace', 'Generate codebase.md from current git workspace').hideHelp()) .option('--from-mr ', 'Extract learning and codebase suggestions from a merged MR/PR URL') .option('--from-iwiki ', 'Import documents from iWiki Space ID or page URL (requires TAI_PAT_TOKEN)') - .option('--resume', 'Resume an interrupted import session') + .addOption(new Option('--resume', 'Resume an interrupted import session').hideHelp()) .option('--all', 'Accept all suggestions without interactive confirmation') - .option('--output ', 'Write drafts to this directory instead of pushing to team repo') - .option('--existing-codebase ', 'Path to existing codebase.md (used with --from-mr; overrides auto-detection from team repo)') + .addOption(new Option('--output ', 'Write drafts to this directory instead of pushing to team repo').hideHelp()) + .addOption(new Option('--existing-codebase ', 'Path to existing codebase.md (used with --from-mr; overrides auto-detection from team repo)').hideHelp()) .option('--from-repo ', 'Clone a remote repo and generate per-repo codebase summary') - .option('--ssh', 'Force SSH clone even if HTTPS token is available') - .option('--domain ', 'Skip AI recommendation and assign repo to this domain explicitly') + .addOption(new Option('--ssh', 'Force SSH clone even if HTTPS token is available').hideHelp()) + .addOption(new Option('--domain ', 'Skip AI recommendation and assign repo to this domain explicitly').hideHelp()) .option('--from-repo-list ', 'Batch import repos from a YAML whitelist') - .option('--concurrency ', 'Concurrent repos for --from-repo-list (default 3)', '3') - .option('--skip-aggregate', 'Skip domain-*.md / index.md regeneration') + .addOption(new Option('--concurrency ', 'Concurrent repos for --from-repo-list (default 3)').default('3').hideHelp()) + .addOption(new Option('--skip-aggregate', 'Skip domain-*.md / index.md regeneration').hideHelp()) .option('--incremental', 'Use cached clone with fetch+reset (with --from-repo or --from-repo-list)') .option('--from-org ', 'List repos under an org and bootstrap whitelist + domains') - .option('--bootstrap', 'Run interactive review after --from-org') - .option('--max-repos ', 'Cap on repos pulled from --from-org (default 200)', '200') - .option('--exclude-archived', 'Exclude archived repos from --from-org (default true)') - .option('--include-pattern ', 'Regex to include repos by full name (used with --from-org)') - .option('--exclude-pattern ', 'Regex to exclude repos by full name (used with --from-org)') - .option('--skip-import', 'Only write drafts; skip the actual --from-repo-list run') - .option('--iwiki-dual', 'Enable dual-output mode for --from-iwiki (write codebase sections in addition to learning)') - .option('--require-review', 'Defer codebase section writes to .teamai/pending-review.jsonl for human review') + .addOption(new Option('--bootstrap', 'Run interactive review after --from-org').hideHelp()) + .addOption(new Option('--max-repos ', 'Cap on repos pulled from --from-org (default 200)').default('200').hideHelp()) + .addOption(new Option('--exclude-archived', 'Exclude archived repos from --from-org (default true)').hideHelp()) + .addOption(new Option('--include-pattern ', 'Regex to include repos by full name (used with --from-org)').hideHelp()) + .addOption(new Option('--exclude-pattern ', 'Regex to exclude repos by full name (used with --from-org)').hideHelp()) + .addOption(new Option('--skip-import', 'Only write drafts; skip the actual --from-repo-list run').hideHelp()) + .addOption(new Option('--iwiki-dual', 'Enable dual-output mode for --from-iwiki (write codebase sections in addition to learning)').hideHelp()) + .addOption(new Option('--require-review', 'Defer codebase section writes to .teamai/pending-review.jsonl for human review').hideHelp()) .action(async (cmdOpts) => { const globalOpts = program.opts() as GlobalOptions; const { importCmd } = await import('./import.js'); @@ -604,7 +611,8 @@ program }); program - .command('mr-hint') + .command('mr-hint', { hidden: true }) + .description('Hint AI about recently merged but un-imported MRs (SessionStart hook)') .option('--stdin', 'Read hook data from STDIN') .option('--tool ', 'Source AI tool (claude / codebuddy / cursor)') @@ -618,18 +626,18 @@ program program .command('codebase') .description('Inspect and maintain team-codebase outputs') - .option('--extract [path]', 'Extract code knowledge and build graph from source') - .option('--incremental', 'Only re-extract changed files (requires prior manifest)') - .option('--project ', 'Project slug for extract output (default: directory name)') - .option('--max-files ', 'Max source files to scan (default: 200)') - .option('--upgrade-wiki', 'Migrate docs/team-codebase/ to teamwiki/ graph format') + .addOption(new Option('--extract [path]', 'Extract code knowledge and build graph from source').hideHelp()) + .addOption(new Option('--incremental', 'Only re-extract changed files (requires prior manifest)').hideHelp()) + .addOption(new Option('--project ', 'Project slug for extract output (default: directory name)').hideHelp()) + .addOption(new Option('--max-files ', 'Max source files to scan (default: 200)').hideHelp()) + .addOption(new Option('--upgrade-wiki', 'Migrate docs/team-codebase/ to teamwiki/ graph format').hideHelp()) .option('--lint', 'Run global consistency lint over docs/team-codebase') .option('--fix', 'Apply low-risk mechanical fixes (only with --lint)') - .option('--severity ', 'Minimum severity to report: high|medium|low|info', 'info') - .option('--stale-days ', 'Threshold for sync-stale check', '60') - .option('--pending-review-threshold ', 'Threshold for pending-review backlog', '10') + .addOption(new Option('--severity ', 'Minimum severity to report: high|medium|low|info').default('info').hideHelp()) + .addOption(new Option('--stale-days ', 'Threshold for sync-stale check').default('60').hideHelp()) + .addOption(new Option('--pending-review-threshold ', 'Threshold for pending-review backlog').default('10').hideHelp()) .option('--json', 'Output report as JSON (suitable for CI)') - .option('--output ', 'Custom team-codebase root (mirrors --from-repo)') + .addOption(new Option('--output ', 'Custom team-codebase root (mirrors --from-repo)').hideHelp()) .action(async (cmdOpts) => { const globalOpts = program.opts() as GlobalOptions; const { codebaseCmd } = await import('./codebase-cmd.js'); @@ -667,7 +675,8 @@ program }); program - .command('domains [repoUrl]') + .command('domains [repoUrl]', { hidden: true }) + .description('Inspect / accept / reject domain-drift signals (subcommand: drift)') .option('--apply', 'Apply drift for the given repoUrl') .option('--apply-all', 'Apply all drift items above confidence threshold') @@ -690,7 +699,8 @@ program // ─── Unified hook dispatch (replaces individual hook subcommands) ──── program - .command('hook-dispatch ') + .command('hook-dispatch ', { hidden: true }) + .description('Unified hook dispatcher — handles all teamai hooks for a given event in one process') .option('--tool ', 'Tool identifier (e.g. claude, claude-internal, cursor)') .option('--matcher ', 'Hook matcher for PostToolUse (e.g. Skill, Bash)') From 9b3ce45c0e606f3ec961331e7733b0c06a3b067e Mon Sep 17 00:00:00 2001 From: m0Nst3r873 Date: Thu, 25 Jun 2026 17:49:13 +0800 Subject: [PATCH 21/21] feat(mcp): add MCP server for AI dialog plugin integration Enable teamai to run natively inside IDE dialog panels (CodeBuddy Chat, Cursor Chat, Claude Desktop) without requiring Bash tool or PATH setup. New command: `teamai mcp` starts a stdio MCP server exposing: - teamai_recall: search team knowledge base (BM25 + graph-boost) - teamai_pull: sync team resources - teamai_status: show local vs repo diff - teamai_contribute: contribute learnings directly - teamai_import_repo: import repository knowledge graph - teamai_list: list team resources - teamai_codebase_lint: knowledge graph health check Auto-registration: `teamai pull` now registers the MCP server in ~/.claude/mcp.json, ~/.codebuddy/mcp.json, ~/.cursor/mcp.json. --- package-lock.json | 1052 ++++++++++++++++++++++++++++++++++++++++- package.json | 1 + src/contribute-mcp.ts | 45 ++ src/index.ts | 8 + src/mcp-register.ts | 69 +++ src/mcp-server.ts | 122 +++++ src/pull.ts | 8 + 7 files changed, 1285 insertions(+), 20 deletions(-) create mode 100644 src/contribute-mcp.ts create mode 100644 src/mcp-register.ts create mode 100644 src/mcp-server.ts diff --git a/package-lock.json b/package-lock.json index f72644f..39d42c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.16.8", "license": "MIT", "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "chalk": "^5.3.0", "commander": "^12.1.0", "fs-extra": "^11.2.0", @@ -527,6 +528,18 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@hutson/parse-repository-url": { "version": "3.0.2", "resolved": "https://mirrors.tencent.com/npm/@hutson/parse-repository-url/-/parse-repository-url-3.0.2.tgz", @@ -652,6 +665,46 @@ "resolved": "https://mirrors.tencent.com/npm/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://mirrors.tencent.com/npm/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1196,6 +1249,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://mirrors.tencent.com/npm/acorn/-/acorn-8.16.0.tgz", @@ -1214,6 +1280,39 @@ "integrity": "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==", "dev": true }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1281,6 +1380,43 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "2.0.3", "resolved": "https://mirrors.tencent.com/npm/brace-expansion/-/brace-expansion-2.0.3.tgz", @@ -1311,6 +1447,15 @@ "esbuild": ">=0.18" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://mirrors.tencent.com/npm/cac/-/cac-6.7.14.tgz", @@ -1320,6 +1465,35 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/camelcase": { "version": "5.3.1", "resolved": "https://mirrors.tencent.com/npm/camelcase/-/camelcase-5.3.1.tgz", @@ -1578,6 +1752,28 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/conventional-changelog": { "version": "3.1.25", "resolved": "https://mirrors.tencent.com/npm/conventional-changelog/-/conventional-changelog-3.1.25.tgz", @@ -1839,17 +2035,51 @@ "node": ">=10" } }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://mirrors.tencent.com/npm/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://mirrors.tencent.com/npm/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1936,6 +2166,15 @@ "node": ">=6" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-indent": { "version": "6.1.0", "resolved": "https://mirrors.tencent.com/npm/detect-indent/-/detect-indent-6.1.0.tgz", @@ -2047,17 +2286,46 @@ "node": ">=4" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://mirrors.tencent.com/npm/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://mirrors.tencent.com/npm/error-ex/-/error-ex-1.3.4.tgz", @@ -2067,12 +2335,42 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://mirrors.tencent.com/npm/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://mirrors.tencent.com/npm/esbuild/-/esbuild-0.27.3.tgz", @@ -2123,6 +2421,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://mirrors.tencent.com/npm/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -2153,6 +2457,36 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://mirrors.tencent.com/npm/expect-type/-/expect-type-1.3.0.tgz", @@ -2162,6 +2496,67 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://mirrors.tencent.com/npm/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -2173,6 +2568,28 @@ "node": ">=0.10.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://mirrors.tencent.com/npm/fdir/-/fdir-6.5.0.tgz", @@ -2205,6 +2622,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://mirrors.tencent.com/npm/find-up/-/find-up-5.0.0.tgz", @@ -2293,6 +2731,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "11.3.4", "resolved": "https://mirrors.tencent.com/npm/fs-extra/-/fs-extra-11.3.4.tgz", @@ -2324,7 +2780,6 @@ "version": "1.1.2", "resolved": "https://mirrors.tencent.com/npm/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2349,6 +2804,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-pkg-repo": { "version": "4.2.1", "resolved": "https://mirrors.tencent.com/npm/get-pkg-repo/-/get-pkg-repo-4.2.1.tgz", @@ -2407,6 +2886,19 @@ "xtend": "~4.0.1" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/git-raw-commits": { "version": "2.0.11", "resolved": "https://mirrors.tencent.com/npm/git-raw-commits/-/git-raw-commits-2.0.11.tgz", @@ -2511,6 +3003,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://mirrors.tencent.com/npm/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2578,11 +3082,22 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://mirrors.tencent.com/npm/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -2590,6 +3105,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://mirrors.tencent.com/npm/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -2610,16 +3134,52 @@ "dependencies": { "yallist": "^4.0.0" }, - "engines": { - "node": ">=10" + "engines": { + "node": ">=10" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://mirrors.tencent.com/npm/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://mirrors.tencent.com/npm/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true - }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://mirrors.tencent.com/npm/indent-string/-/indent-string-4.0.0.tgz", @@ -2632,8 +3192,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://mirrors.tencent.com/npm/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "1.3.8", @@ -2641,6 +3200,24 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://mirrors.tencent.com/npm/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -2708,6 +3285,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-text-path": { "version": "1.0.1", "resolved": "https://mirrors.tencent.com/npm/is-text-path/-/is-text-path-1.0.1.tgz", @@ -2740,8 +3323,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://mirrors.tencent.com/npm/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -2808,6 +3390,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://mirrors.tencent.com/npm/joycon/-/joycon-3.1.1.tgz", @@ -2847,6 +3438,18 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://mirrors.tencent.com/npm/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -3070,6 +3673,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/meow": { "version": "8.1.2", "resolved": "https://mirrors.tencent.com/npm/meow/-/meow-8.1.2.tgz", @@ -3185,6 +3806,43 @@ "semver": "bin/semver" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://mirrors.tencent.com/npm/mimic-function/-/mimic-function-5.0.1.tgz", @@ -3328,6 +3986,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://mirrors.tencent.com/npm/neo-async/-/neo-async-2.6.2.tgz", @@ -3353,11 +4020,43 @@ "version": "4.1.1", "resolved": "https://mirrors.tencent.com/npm/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://mirrors.tencent.com/npm/onetime/-/onetime-7.0.0.tgz", @@ -3454,6 +4153,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://mirrors.tencent.com/npm/path-exists/-/path-exists-4.0.0.tgz", @@ -3467,7 +4175,6 @@ "version": "3.1.1", "resolved": "https://mirrors.tencent.com/npm/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -3494,6 +4201,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/path-type": { "version": "3.0.0", "resolved": "https://mirrors.tencent.com/npm/path-type/-/path-type-3.0.0.tgz", @@ -3566,6 +4283,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://mirrors.tencent.com/npm/pkg-types/-/pkg-types-1.3.1.tgz", @@ -3653,6 +4379,19 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/q": { "version": "1.5.1", "resolved": "https://mirrors.tencent.com/npm/q/-/q-1.5.1.tgz", @@ -3664,6 +4403,22 @@ "teleport": ">=0.2.0" } }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quick-lru": { "version": "4.0.1", "resolved": "https://mirrors.tencent.com/npm/quick-lru/-/quick-lru-4.0.1.tgz", @@ -3673,6 +4428,30 @@ "node": ">=8" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/read-pkg": { "version": "3.0.0", "resolved": "https://mirrors.tencent.com/npm/read-pkg/-/read-pkg-3.0.0.tgz", @@ -3843,6 +4622,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://mirrors.tencent.com/npm/resolve/-/resolve-1.22.11.tgz", @@ -3931,6 +4719,22 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://mirrors.tencent.com/npm/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3951,6 +4755,12 @@ } ] }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://mirrors.tencent.com/npm/section-matter/-/section-matter-1.0.0.tgz", @@ -3975,11 +4785,61 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://mirrors.tencent.com/npm/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -3991,11 +4851,82 @@ "version": "3.0.0", "resolved": "https://mirrors.tencent.com/npm/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://mirrors.tencent.com/npm/siginfo/-/siginfo-2.0.0.tgz", @@ -4212,6 +5143,15 @@ "node": ">=4" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://mirrors.tencent.com/npm/std-env/-/std-env-3.10.0.tgz", @@ -4512,6 +5452,15 @@ "node": ">=14.0.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://mirrors.tencent.com/npm/tree-kill/-/tree-kill-1.2.2.tgz", @@ -4600,6 +5549,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://mirrors.tencent.com/npm/typedarray/-/typedarray-0.0.6.tgz", @@ -4652,6 +5632,15 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://mirrors.tencent.com/npm/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4668,6 +5657,15 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://mirrors.tencent.com/npm/vite/-/vite-5.4.21.tgz", @@ -5236,7 +6234,6 @@ "version": "2.0.2", "resolved": "https://mirrors.tencent.com/npm/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -5377,6 +6374,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://mirrors.tencent.com/npm/xtend/-/xtend-4.0.2.tgz", @@ -5502,6 +6505,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 302ca0b..818f3a6 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "author": "jeffyxu ", "license": "MIT", "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "chalk": "^5.3.0", "commander": "^12.1.0", "fs-extra": "^11.2.0", diff --git a/src/contribute-mcp.ts b/src/contribute-mcp.ts new file mode 100644 index 0000000..095f572 --- /dev/null +++ b/src/contribute-mcp.ts @@ -0,0 +1,45 @@ +import path from 'node:path'; +import { requireInit } from './config.js'; +import { writeFile, ensureDir } from './utils/fs.js'; +import { autoPushTeamRepo } from './utils/git.js'; +import { log } from './utils/logger.js'; + +export async function contributeFromMcp(opts: { + title: string; + content: string; + tags?: string[]; +}): Promise { + const { localConfig } = await requireInit(); + const repoPath = localConfig.repo.localPath; + const learningsDir = path.join(repoPath, 'learnings'); + await ensureDir(learningsDir); + + const slug = opts.title + .toLowerCase() + .replace(/[^a-z0-9一-鿿]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 60); + const date = new Date().toISOString().slice(0, 10); + const filename = `${slug}-${date}.md`; + + const frontmatter = [ + '---', + `title: "${opts.title}"`, + `author: ${localConfig.username}`, + `date: ${date}`, + opts.tags && opts.tags.length > 0 ? `tags: [${opts.tags.join(', ')}]` : null, + '---', + ].filter(Boolean).join('\n'); + + const fileContent = `${frontmatter}\n\n${opts.content}\n`; + const filePath = path.join(learningsDir, filename); + await writeFile(filePath, fileContent); + + try { + await autoPushTeamRepo(repoPath, `[teamai] contribute: ${opts.title}`); + return `Learning "${opts.title}" contributed and pushed to team repo (${filename}).`; + } catch (e) { + log.debug(`MCP contribute push failed: ${(e as Error).message}`); + return `Learning saved locally (${filename}) but push failed: ${(e as Error).message}`; + } +} diff --git a/src/index.ts b/src/index.ts index e8391d2..66437be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -732,4 +732,12 @@ ciCmd await ciExtractMr({ ...globalOpts, ...cmdOpts }); }); +program + .command('mcp') + .description('Start teamai as an MCP server (for AI dialog plugins)') + .action(async () => { + const { startMcpServer } = await import('./mcp-server.js'); + await startMcpServer(); + }); + program.parse(); diff --git a/src/mcp-register.ts b/src/mcp-register.ts new file mode 100644 index 0000000..7fe4a9a --- /dev/null +++ b/src/mcp-register.ts @@ -0,0 +1,69 @@ +import path from 'node:path'; +import { readJson, writeJson, ensureDir, pathExists } from './utils/fs.js'; +import { log } from './utils/logger.js'; + +interface McpConfig { + mcpServers: Record }>; +} + +const MCP_CONFIG_PATHS: Record = { + claude: '.claude/mcp.json', + codebuddy: '.codebuddy/mcp.json', + cursor: '.cursor/mcp.json', +}; + +export async function registerMcpServer(baseDir?: string): Promise { + const home = baseDir ?? (process.env.HOME ?? ''); + let registered = 0; + + for (const [tool, relPath] of Object.entries(MCP_CONFIG_PATHS)) { + const configPath = path.join(home, relPath); + const configDir = path.dirname(configPath); + + if (!await pathExists(configDir)) { + log.debug(`MCP register: skipping ${tool} (directory not found)`); + continue; + } + + try { + await ensureDir(configDir); + const existing: McpConfig = await readJson(configPath) ?? { mcpServers: {} }; + if (!existing.mcpServers) existing.mcpServers = {}; + + const current = existing.mcpServers['teamai']; + const desired = { command: 'teamai', args: ['mcp'], env: {} }; + + if (current?.command === desired.command && JSON.stringify(current?.args) === JSON.stringify(desired.args)) { + log.debug(`MCP register: ${tool} already configured`); + continue; + } + + existing.mcpServers['teamai'] = desired; + await writeJson(configPath, existing); + registered++; + log.debug(`MCP register: registered teamai server in ${tool}`); + } catch (e) { + log.warn(`MCP register: failed for ${tool}: ${(e as Error).message}`); + } + } + + return registered; +} + +export async function unregisterMcpServer(baseDir?: string): Promise { + const home = baseDir ?? (process.env.HOME ?? ''); + + for (const [tool, relPath] of Object.entries(MCP_CONFIG_PATHS)) { + const configPath = path.join(home, relPath); + try { + const existing: McpConfig | null = await readJson(configPath); + if (existing?.mcpServers?.['teamai']) { + delete existing.mcpServers['teamai']; + await writeJson(configPath, existing); + log.debug(`MCP unregister: removed teamai from ${tool}`); + } + } catch { + // File doesn't exist or can't be read — that's fine + } + } +} diff --git a/src/mcp-server.ts b/src/mcp-server.ts new file mode 100644 index 0000000..8731794 --- /dev/null +++ b/src/mcp-server.ts @@ -0,0 +1,122 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; + +export async function startMcpServer(): Promise { + const server = new McpServer({ + name: 'teamai', + version: '1.0.0', + }); + + server.tool( + 'teamai_recall', + 'Search team knowledge base (learnings, skills, docs, rules, codebase graph). Returns ranked results with BM25 + graph-boost scoring.', + { query: z.string().describe('Search keywords'), depth: z.enum(['route', 'context', 'lookup']).optional().describe('Result depth level') }, + async ({ query, depth }) => { + const { recall } = await import('./recall.js'); + const output = await captureStdout(() => recall(query, { depth: depth ?? 'context' })); + return { content: [{ type: 'text', text: output || 'No matching knowledge found.' }] }; + }, + ); + + server.tool( + 'teamai_pull', + 'Pull latest team resources (skills, rules, docs, learnings) from the team repo.', + {}, + async () => { + const { pull } = await import('./pull.js'); + await pull({ silent: true }); + return { content: [{ type: 'text', text: 'Team resources pulled successfully.' }] }; + }, + ); + + server.tool( + 'teamai_status', + 'Show diff between local resources and the team repo.', + {}, + async () => { + const output = await captureStdout(async () => { + const { status } = await import('./status.js'); + await status({}); + }); + return { content: [{ type: 'text', text: output || 'Everything up to date.' }] }; + }, + ); + + server.tool( + 'teamai_contribute', + 'Contribute a learning document to the team knowledge base.', + { + title: z.string().describe('Title of the learning'), + content: z.string().describe('Markdown content of the learning'), + tags: z.array(z.string()).optional().describe('Tags for categorization'), + }, + async ({ title, content, tags }) => { + const { contributeFromMcp } = await import('./contribute-mcp.js'); + const result = await contributeFromMcp({ title, content, tags }); + return { content: [{ type: 'text', text: result }] }; + }, + ); + + server.tool( + 'teamai_import_repo', + 'Import a remote repository into the team knowledge graph (teamwiki/). Extracts code facts, builds graph, and pushes to team repo.', + { + url: z.string().describe('Repository URL (HTTPS or SSH)'), + incremental: z.boolean().optional().describe('Only re-extract changed files'), + }, + async ({ url, incremental }) => { + try { + const { importFromRepo } = await import('./import-repo.js'); + await importFromRepo({ url, incremental: incremental ?? false }); + return { content: [{ type: 'text', text: `Repository ${url} imported successfully. Knowledge graph updated.` }] }; + } catch (e) { + return { content: [{ type: 'text', text: `Import failed: ${(e as Error).message}` }] }; + } + }, + ); + + server.tool( + 'teamai_list', + 'List team knowledge resources.', + { type: z.enum(['skills', 'rules', 'docs', 'learnings']).optional().describe('Resource type to list (default: all)') }, + async ({ type }) => { + const output = await captureStdout(async () => { + const { list } = await import('./status.js'); + await list(type ?? undefined, {}); + }); + return { content: [{ type: 'text', text: output || 'No resources found.' }] }; + }, + ); + + server.tool( + 'teamai_codebase_lint', + 'Run knowledge graph health check (node connectivity, stale manifest, orphan detection).', + {}, + async () => { + const output = await captureStdout(async () => { + const { codebaseCmd } = await import('./codebase-cmd.js'); + await codebaseCmd({ lint: true }); + }); + return { content: [{ type: 'text', text: output || 'Lint completed with no issues.' }] }; + }, + ); + + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const originalWrite = process.stdout.write; + process.stdout.write = (chunk: string | Uint8Array) => { + chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString()); + return true; + }; + try { + await fn(); + } finally { + process.stdout.write = originalWrite; + } + return chunks.join(''); +} diff --git a/src/pull.ts b/src/pull.ts index aed4677..e9b0f72 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1067,4 +1067,12 @@ export async function pull(options: GlobalOptions): Promise { log.debug(`Source pull skipped: ${(e as Error).message}`); } } + + // Register teamai MCP server in AI tool configs + try { + const { registerMcpServer } = await import('./mcp-register.js'); + await registerMcpServer(); + } catch { + // Non-blocking: MCP registration failure doesn't affect pull + } } \ No newline at end of file