diff --git a/AGENTS.md b/AGENTS.md index 98a6ec9..2762cf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,9 +4,9 @@ > 它将本地数字资产的原始数据(代码库、笔记、Skill、工作流)编译为 AI 可决策的结构化情境,不负责思考,不负责执行,只负责感知、编码、持久化、检索。 -- **当前阶段**:阶段八 → v0.17.0-dev 进行中(Agent Memory 向量存储 / Embedding 职责外迁) -- **当前版本**:v0.17.0-dev(Schema 34,60 MCP tools,442 tests) -- **已完成里程碑**:Registry God Object 完全拆解(10 子模块提取)+ 18 workspace crates 提取 + MCP Python SDK 1.16.0 兼容修复 + repo.rs trait 化 + flaky 测试根治(RF-2.1/2.2/2.3)+ 许可证迁移 + health 性能优化(-44%)+ index skip-embeddings + batch encoding 实验 + RF-6 清零 + 架构治理文档(ADR/不变量清单)+ Tantivy BM25 代码符号搜索(P1)+ AppContext 职责拆分 Phase 1/2(storage.rs 860→430 行)+ 架构不变量 CI(G5/T11/T12)+ Embedding 多后端(Candle/Ollama 配置切换, P3)+ EnvVersionCache 扩展(9 工具链检测, P4)+ **v0.16.0 Agent Contexts(P1/P2/P3)**:`agent_contexts`/`agent_memories`/`context_entity_links` Schema + 9 个 Session MCP tools + Context-aware Skill Runtime(`DEVBASE_ACTIVE_CONTEXT` 注入)+ **v0.16.1 Workflow-Session Binding**:`workflow_executions.context_id` + 执行自动绑定 Active Context + **v0.17.0-dev Embedding Externalization**:`embedding` 从 default features 移除(Candle/Ollama 降级为 opt-in `llm-backend`)+ Schema 34 向量存储 + `cosine_similarity` SQLite UDF + `devkit_session_recall` / `devkit_session_index`(60 tools) +- **当前阶段**:阶段九 → v0.18.0 进行中(ClaudeCode 工作流深度集成) +- **当前版本**:v0.18.0-dev(Schema 34,64 MCP tools,446 tests) +- **已完成里程碑**:Registry God Object 完全拆解(10 子模块提取)+ 18 workspace crates 提取 + MCP Python SDK 1.16.0 兼容修复 + repo.rs trait 化 + flaky 测试根治(RF-2.1/2.2/2.3)+ 许可证迁移 + health 性能优化(-44%)+ index skip-embeddings + batch encoding 实验 + RF-6 清零 + 架构治理文档(ADR/不变量清单)+ Tantivy BM25 代码符号搜索(P1)+ AppContext 职责拆分 Phase 1/2(storage.rs 860→430 行)+ 架构不变量 CI(G5/T11/T12)+ Embedding 多后端(Candle/Ollama 配置切换, P3)+ EnvVersionCache 扩展(9 工具链检测, P4)+ **v0.16.0 Agent Contexts(P1/P2/P3)**:`agent_contexts`/`agent_memories`/`context_entity_links` Schema + 9 个 Session MCP tools + Context-aware Skill Runtime(`DEVBASE_ACTIVE_CONTEXT` 注入)+ **v0.16.1 Workflow-Session Binding**:`workflow_executions.context_id` + 执行自动绑定 Active Context + **v0.17.0 Embedding Externalization**:`embedding` 从 default features 移除(Candle/Ollama 降级为 opt-in `llm-backend`)+ Schema 34 向量存储 + `cosine_similarity` SQLite UDF + `devkit_session_recall` / `devkit_session_index`(60 tools)+ **v0.18.0-dev ClaudeCode Integration**:`devkit_project_brief`(Markdown 项目简报)+ `devkit_impact_analysis`(修改影响范围分析)+ `devkit_session_export` / `devkit_session_import` + `scripts/devbase-claude.ps1` 启动器(自动注入 `.claude/CLAUDE.md`)+ RFC `docs/RFC/claudecode-workflow-integration.md`(64 tools) - **核心方向**:让 Kimi CLI 在调用文件工具之前,先通过 devbase 获得"该读哪些文件、为什么读、它们之间的关系" - **本质分析**:见 `vault/99-Meta/devbase-essence-analysis-20260430.md` 与 `docs/architecture/redefinition.md` - **设计文档**: @@ -23,10 +23,10 @@ Skill Runtime 全生命周期已落地(含依赖管理 Schema v15),Schema - **Workspace**:`%LOCALAPPDATA%\devbase\workspace/` —— 文件系统 = source of truth - `vault/` —— PARA 结构:00-Inbox, 01-Projects, 02-Areas, 03-Resources, 04-Archives, 99-Meta - `assets/` —— 二进制资源 -- **MCP Server**:stdio only,**60 个 tools**(含 5 个 vault tools + 8 个代码分析工具 + 4 个 embedding/搜索工具 + 4 个 Skill Runtime tools + 3 个 Workflow/评分 tools + 1 个报告工具 + 1 个 arXiv 工具 + 2 个 KnownLimit tools + 3 个 Relation tools + 9 个 Agent Context tools + 2 个 Agent Memory 向量工具 + 1 个 streaming index 工具 + 1 个 oplog 工具);配置见 `mcp.json` +- **MCP Server**:stdio only,**64 个 tools**(含 5 个 vault tools + 8 个代码分析工具 + 4 个 embedding/搜索工具 + 4 个 Skill Runtime tools + 3 个 Workflow/评分 tools + 1 个报告工具 + 1 个 arXiv 工具 + 2 个 KnownLimit tools + 3 个 Relation tools + 11 个 Agent Context tools + 2 个 ClaudeCode 集成工具 + 1 个 streaming index 工具 + 1 个 oplog 工具);配置见 `mcp.json` - **Kimi CLI 集成**:MCP server 已通过 `kimi mcp add` 注册,端到端验证通过(`kimi --print` 成功调用 `devkit_health`);项目级 skill 位于 `.kimi/skills/devbase-project/SKILL.md` - **统一节点模型**:`core::node::{Node, NodeType, Edge}` —— GitRepo / VaultNote / Asset / ExternalLink -- **当前测试**:442+ lib passed / 0 failed / 3 ignored + 11/11 integration passed(`tests/cli.rs`) +- **当前测试**:446+ lib passed / 0 failed / 3 ignored + 11/11 integration passed(`tests/cli.rs`) - **编译状态**:0 warning / 0 vulnerabilities(`cargo audit` 干净,除上游 `tokei` 的 `RUSTSEC-2020-0163`) - **Workspace 结构**:`crates/` 目录已启用,18 个零耦合模块已提取为独立 crate(`devbase-symbol-links`, `devbase-sync-protocol`, `devbase-core-types`, `devbase-syncthing-client`, `devbase-vault-frontmatter`, `devbase-vault-wikilink`, `devbase-workflow-interpolate`, `devbase-workflow-model`, `devbase-registry-health`, `devbase-registry-metrics`, `devbase-registry-workspace`, `devbase-embedding`, `devbase-skill-runtime-types`, `devbase-skill-runtime-parser`, `devbase-registry-entity`, `devbase-registry-relation`, `devbase-registry-call-graph`, `devbase-registry-dead-code`, `devbase-registry-code-symbols`) - **Workflow Engine**:YAML 解析 + 拓扑调度 + batch 并行执行 + 5 种 step 类型(skill/subworkflow/parallel/condition/loop) diff --git a/docs/RFC/claudecode-workflow-integration.md b/docs/RFC/claudecode-workflow-integration.md new file mode 100644 index 0000000..62fe8f8 --- /dev/null +++ b/docs/RFC/claudecode-workflow-integration.md @@ -0,0 +1,247 @@ +# RFC: ClaudeCode 工作流深度集成 — v0.18.0 + +**Status**: Draft +**Target Version**: v0.18.0 +**Author**: juice094 +**Date**: 2026-05-13 + +## 1. 用例分析 + +ClaudeCode 是 Anthropic 推出的终端 AI 编程助手。其典型工作流: + +``` +1. 启动 → Claude 扫描项目目录,建立初步理解(往往耗时且片面) +2. 需求理解 → 用户用自然语言描述需求 +3. 文件探索 → Claude 用 grep/find 暴力搜索相关代码 +4. 编辑执行 → 读文件 → 改文件 → 验证(循环) +5. 提交 → git add/commit/push +6. 会话结束 → 对话历史丢失,下次从零开始 +``` + +**痛点**: +- P1: 启动扫描慢,对大型仓库(如 devbase 本身)需要数十秒才能建立上下文 +- P2: 代码搜索依赖关键词匹配,无法基于语义("找认证相关的代码" → grep "auth" 漏掉 "login") +- P3: 修改前无影响分析,经常漏改调用点或测试 +- P4: 会话不持久,跨会话知识丢失(昨天的决策今天不记得) +- P5: 无法自动执行标准化工作流(如:修改 → clippy → test → commit message生成) + +## 2. 设计目标 + +让 devbase 成为 ClaudeCode 的"外接海马体": + +| 环节 | devbase 能力 | ClaudeCode 收益 | +|------|-------------|----------------| +| 启动 | `devkit_project_brief` | 秒级获得项目全景,替代暴力扫描 | +| 探索 | `devkit_hybrid_search` | 语义搜索找代码,减少 50% 文件读取 | +| 编辑前 | `devkit_impact_analysis` | 修改前预知影响范围,降低回归风险 | +| 验证 | `devkit_evaluate` | 一键运行 clippy/test/fmt,保障质量 | +| 会话中 | `devkit_session_save/capture` | 关键决策实时沉淀为项目记忆 | +| 跨会话 | `devkit_session_recall` | 启动时自动注入相关历史决策 | +| 复杂任务 | Workflow Engine | 标准化重构/发布/审查流程 | + +## 3. 核心功能设计 + +### 3.1 Project Brief Generator (`devkit_project_brief`) + +**问题**:Claude 启动时需要快速理解项目结构、关键模块、技术约束。 + +**设计**: +```json +{ + "repo_id": "devbase", + "format": "markdown" // markdown | json +} +``` + +**输出结构**: +```markdown +# Project Brief: devbase + +## Overview +本地优先的 AI 情境编译器。Rust CLI,SQLite + Tantivy 索引。 + +## Key Modules +- src/mcp/ — MCP Server(60 tools) +- src/registry/ — SQLite schema + migrations +- src/workflow/ — YAML workflow engine +- crates/ — 18 个零耦合 workspace crates + +## Dependency Graph(高内聚模块) +- mcp → registry → storage +- workflow → skill_runtime → registry + +## Active Contexts +- feat/claudecode-integration(当前分支关联的 context) + +## Known Limits +- [L3-001] Windows 路径长名问题(已缓解) +- [L3-002] Candle 编译时间(v0.17.0 已外迁) + +## Recent Changes(最近 7 天) +- v0.17.0: Agent Memory 向量存储 +- v0.16.1: Workflow-Session 绑定 +``` + +**实现**:聚合 `repo_modules` + `code_symbols` + `known_limits` + `oplog` + `agent_contexts` 数据,生成 LLM-optimized Markdown。 + +### 3.2 Impact Analysis (`devkit_impact_analysis`) + +**问题**:Claude 说"我要重构 `run_skill`",但不知道谁调用了它、哪些测试覆盖它。 + +**设计**: +```json +{ + "repo_id": "devbase", + "symbol_name": "run_skill", + "depth": 2 // 调用链深度 +} +``` + +**输出**: +```json +{ + "symbol": "run_skill", + "file": "src/skill_runtime/executor.rs:12", + "callers": [ + {"symbol": "execute_skill_step", "file": "src/workflow/executor.rs:45"}, + {"symbol": "test_run_skill_success", "file": "src/skill_runtime/executor.rs:502"} + ], + "callees": [ + {"symbol": "resolve_interpreter", "file": "src/skill_runtime/executor.rs:257"}, + {"symbol": "recall_context_memories", "file": "src/skill_runtime/executor.rs:231"} + ], + "related": [ + {"symbol": "ExecutionResult", "file": "src/skill_runtime/mod.rs:45", "link_type": "return_type"} + ], + "tests": [ + "test_run_skill_success", + "test_run_skill_not_found", + "test_hard_veto_guard" + ], + "history": [ + {"date": "2026-05-13", "change": "添加 auto-recall 逻辑", "commit": "b1fff28"} + ] +} +``` + +**实现**:复用现有的 `call_graph` + `related_symbols` + `dead_code` + `code_symbols` 数据,通过统一的 `impact_analysis` API 聚合。 + +### 3.3 Session-Aware Claude Wrapper + +**问题**:ClaudeCode 会话结束即丢失,无法跨会话保持上下文。 + +**设计**(无需修改 ClaudeCode 本身): + +提供一个 wrapper 脚本 `devbase-claude`: + +```bash +#!/bin/bash +# devbase-claude wrapper + +# 1. 读取 active context +CONTEXT_ID=$(devbase context resolve) + +# 2. 如果有 context,导出 memories 为 Claude system prompt 补充 +if [ -n "$CONTEXT_ID" ]; then + MEMORIES=$(devbase session recall --context-id "$CONTEXT_ID" --limit 10) + export CLAUDE_SYSTEM_PROMPT_EXTRA="$MEMORIES" +fi + +# 3. 生成 project brief +BRIEF=$(devbase project brief --repo-id "$(basename $(pwd))") +export CLAUDE_PROJECT_BRIEF="$BRIEF" + +# 4. 启动 ClaudeCode +claude "$@" + +# 5. 会话结束后,自动捕获对话摘要(需要用户确认) +echo "Capture this session to devbase? [y/N]" +read -r CAPTURE +if [ "$CAPTURE" = "y" ]; then + devbase session capture "$CONTEXT_ID" "decision" "$(cat /tmp/claude-summary.txt)" +fi +``` + +**长期**:向 Anthropic 提议官方 MCP integration,让 ClaudeCode 原生支持 devbase tools。 + +### 3.4 Standardized Development Workflow + +**设计**:预置 workflow YAML,Claude 可通过 `devkit_workflow_run` 触发。 + +**`workflows/refactor.yml`**: +```yaml +id: safe-refactor +name: Safe Refactor Pipeline +inputs: + - name: repo_id + - name: symbol_name + - name: description +steps: + - id: analyze + step_type: skill + skill: devbase-impact-analysis + inputs: + repo_id: "{{ inputs.repo_id }}" + symbol_name: "{{ inputs.symbol_name }}" + + - id: edit + step_type: skill + skill: claude-code-edit + inputs: + description: "{{ inputs.description }}" + affected_files: "{{ steps.analyze.outputs.affected_files }}" + depends_on: [analyze] + + - id: evaluate + step_type: skill + skill: devbase-evaluate + inputs: + repo_id: "{{ inputs.repo_id }}" + depends_on: [edit] + + - id: capture + step_type: skill + skill: devbase-session-capture + inputs: + context_id: "{{ env.DEVBASE_ACTIVE_CONTEXT }}" + memory_type: "decision" + content: "Refactored {{ inputs.symbol_name }}: {{ inputs.description }}" + depends_on: [evaluate] +``` + +## 4. 实施路线 + +### P1: Project Brief + Impact Analysis(2 周) + +- [ ] `devkit_project_brief` MCP tool + CLI command +- [ ] `devkit_impact_analysis` MCP tool(聚合 call_graph + related_symbols + tests) +- [ ] 更新 `docs/guides/claudecode-integration.md` + +### P2: Session Wrapper + Auto-Capture(1 周) + +- [ ] `devbase-claude` wrapper 脚本(POSIX + PowerShell) +- [ ] 会话结束后自动摘要提取(基于 git diff + oplog) +- [ ] `devkit_session_export` / `devkit_session_import`(Markdown 格式) + +### P3: Workflow Templates(1 周) + +- [ ] 预置 workflow YAML: `safe-refactor`, `code-review`, `release-prep` +- [ ] Workflow 模板注册到 devbase skill registry +- [ ] TUI workflow 模板选择器 + +## 5. 成功指标 + +| 指标 | 基线 | 目标 | +|------|------|------| +| Claude 启动理解时间 | 30-60s(大型仓库) | < 5s(Project Brief 注入) | +| 代码搜索轮次 | 5-10 次 grep | 2-3 次 hybrid search | +| 修改后回归 Bug | 频繁 | 降低 50%(Impact Analysis) | +| 跨会话知识保留 | 0% | 80%(Session Memory) | + +## 6. 风险评估 + +| 风险 | 缓解 | +|------|------| +| ClaudeCode API 封闭,无法深度集成 | 先通过 wrapper 脚本 + MCP tools 外围集成;长期推动官方支持 | +| Project Brief 过大导致 token 爆炸 | 支持 `max_tokens` 限制 + 分层摘要(overview → modules → limits) | +| Impact Analysis 误报 | 结合 test coverage + manual review flag | diff --git a/scripts/devbase-claude.ps1 b/scripts/devbase-claude.ps1 new file mode 100644 index 0000000..ab866fb --- /dev/null +++ b/scripts/devbase-claude.ps1 @@ -0,0 +1,63 @@ +#Requires -Version 7 +# devbase-claude.ps1 — ClaudeCode launcher with devbase Project Brief injection +# v0.18.0 + +param( + [string]$RepoId = "", + [switch]$SkipBrief, + [switch]$CaptureOnExit +) + +$ErrorActionPreference = "Stop" + +# 1. Resolve repo_id from current directory if not provided +if (-not $RepoId) { + $cwd = (Get-Location).Path + # Try to get repo_id from devbase registry by matching local_path + $repoJson = devbase query repos --json 2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($repoJson) { + $match = $repoJson | Where-Object { $cwd -like "*$($_.local_path)*" -or $cwd -like "*$($_.id)*" } | Select-Object -First 1 + if ($match) { + $RepoId = $match.id + Write-Host "[devbase] Detected repo: $RepoId" -ForegroundColor Cyan + } + } +} + +# 2. Generate Project Brief and inject into .claude/CLAUDE.md +if ($RepoId -and -not $SkipBrief) { + Write-Host "[devbase] Generating project brief for $RepoId ..." -ForegroundColor Cyan + $brief = devbase project brief --repo-id $RepoId --max-tokens 3000 2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($brief -and $brief.brief) { + $claudeDir = Join-Path (Get-Location) ".claude" + if (-not (Test-Path $claudeDir)) { + New-Item -ItemType Directory -Path $claudeDir | Out-Null + } + $claudeMd = Join-Path $claudeDir "CLAUDE.md" + $header = "# Devbase Project Brief (auto-generated)\n\n> This file is automatically generated by devbase-claude.ps1.\n> Do not edit manually — run `devbase-claude.ps1 -SkipBrief` to skip regeneration.\n\n" + $content = $header + $brief.brief + Set-Content -Path $claudeMd -Value $content -Encoding UTF8 + Write-Host "[devbase] Injected brief into .claude/CLAUDE.md ($($content.Length) chars)" -ForegroundColor Green + } else { + Write-Warning "[devbase] Failed to generate brief. Proceeding without injection." + } +} + +# 3. Launch ClaudeCode +Write-Host "[devbase] Starting ClaudeCode ..." -ForegroundColor Cyan +& claude @args + +# 4. Post-session capture (optional) +if ($CaptureOnExit -and $RepoId) { + Write-Host "`n[devbase] Capturing session summary ..." -ForegroundColor Cyan + $diff = git diff --stat HEAD 2>$null + if ($diff) { + $summary = "Session changes:`n$diff" + $activeCtx = $env:DEVBASE_ACTIVE_CONTEXT + if (-not $activeCtx) { $activeCtx = $RepoId } + devbase session capture $activeCtx "decision" $summary 2>$null | Out-Null + Write-Host "[devbase] Captured to context '$activeCtx'" -ForegroundColor Green + } else { + Write-Host "[devbase] No git changes detected — nothing to capture." -ForegroundColor DarkGray + } +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index baab400..f7746f6 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -79,6 +79,8 @@ pub enum McpToolEnum { VaultDaily(DevkitVaultDailyTool), VaultGraph(DevkitVaultGraphTool), ProjectContext(DevkitProjectContextTool), + ProjectBrief(DevkitProjectBriefTool), + ImpactAnalysis(DevkitImpactAnalysisTool), CodeSymbols(DevkitCodeSymbolsTool), DependencyGraph(DevkitDependencyGraphTool), CallGraph(DevkitCallGraphTool), @@ -111,6 +113,8 @@ pub enum McpToolEnum { SessionWorkflows(DevkitSessionWorkflowsTool), SessionRecall(DevkitSessionRecallTool), SessionIndex(DevkitSessionIndexTool), + SessionExport(DevkitSessionExportTool), + SessionImport(DevkitSessionImportTool), WorkflowList(DevkitWorkflowListTool), WorkflowRun(DevkitWorkflowRunTool), WorkflowStatus(DevkitWorkflowStatusTool), @@ -147,6 +151,8 @@ impl McpToolEnum { McpToolEnum::VaultSearch(_) => ToolTier::Stable, McpToolEnum::VaultRead(_) => ToolTier::Stable, McpToolEnum::ProjectContext(_) => ToolTier::Stable, + McpToolEnum::ProjectBrief(_) => ToolTier::Beta, + McpToolEnum::ImpactAnalysis(_) => ToolTier::Beta, // Beta: validated but schema may微调, limited edge-case tests McpToolEnum::Scan(_) => ToolTier::Beta, McpToolEnum::Sync(_) => ToolTier::Beta, @@ -199,6 +205,8 @@ impl McpToolEnum { McpToolEnum::SessionWorkflows(_) => ToolTier::Beta, McpToolEnum::SessionRecall(_) => ToolTier::Experimental, McpToolEnum::SessionIndex(_) => ToolTier::Experimental, + McpToolEnum::SessionExport(_) => ToolTier::Experimental, + McpToolEnum::SessionImport(_) => ToolTier::Experimental, McpToolEnum::WorkflowList(_) => ToolTier::Beta, McpToolEnum::WorkflowRun(_) => ToolTier::Beta, McpToolEnum::WorkflowStatus(_) => ToolTier::Beta, @@ -234,6 +242,8 @@ impl McpTool for McpToolEnum { McpToolEnum::VaultDaily(t) => t.name(), McpToolEnum::VaultGraph(t) => t.name(), McpToolEnum::ProjectContext(t) => t.name(), + McpToolEnum::ProjectBrief(t) => t.name(), + McpToolEnum::ImpactAnalysis(t) => t.name(), McpToolEnum::CodeSymbols(t) => t.name(), McpToolEnum::DependencyGraph(t) => t.name(), McpToolEnum::CallGraph(t) => t.name(), @@ -266,6 +276,8 @@ impl McpTool for McpToolEnum { McpToolEnum::SessionWorkflows(t) => t.name(), McpToolEnum::SessionRecall(t) => t.name(), McpToolEnum::SessionIndex(t) => t.name(), + McpToolEnum::SessionExport(t) => t.name(), + McpToolEnum::SessionImport(t) => t.name(), McpToolEnum::WorkflowList(t) => t.name(), McpToolEnum::WorkflowRun(t) => t.name(), McpToolEnum::WorkflowStatus(t) => t.name(), @@ -299,6 +311,8 @@ impl McpTool for McpToolEnum { McpToolEnum::VaultDaily(t) => t.schema(), McpToolEnum::VaultGraph(t) => t.schema(), McpToolEnum::ProjectContext(t) => t.schema(), + McpToolEnum::ProjectBrief(t) => t.schema(), + McpToolEnum::ImpactAnalysis(t) => t.schema(), McpToolEnum::CodeSymbols(t) => t.schema(), McpToolEnum::DependencyGraph(t) => t.schema(), McpToolEnum::CallGraph(t) => t.schema(), @@ -331,6 +345,8 @@ impl McpTool for McpToolEnum { McpToolEnum::SessionWorkflows(t) => t.schema(), McpToolEnum::SessionRecall(t) => t.schema(), McpToolEnum::SessionIndex(t) => t.schema(), + McpToolEnum::SessionExport(t) => t.schema(), + McpToolEnum::SessionImport(t) => t.schema(), McpToolEnum::WorkflowList(t) => t.schema(), McpToolEnum::WorkflowRun(t) => t.schema(), McpToolEnum::WorkflowStatus(t) => t.schema(), @@ -368,6 +384,8 @@ impl McpTool for McpToolEnum { McpToolEnum::VaultDaily(t) => t.invoke(args, ctx).await, McpToolEnum::VaultGraph(t) => t.invoke(args, ctx).await, McpToolEnum::ProjectContext(t) => t.invoke(args, ctx).await, + McpToolEnum::ProjectBrief(t) => t.invoke(args, ctx).await, + McpToolEnum::ImpactAnalysis(t) => t.invoke(args, ctx).await, McpToolEnum::CodeSymbols(t) => t.invoke(args, ctx).await, McpToolEnum::DependencyGraph(t) => t.invoke(args, ctx).await, McpToolEnum::CallGraph(t) => t.invoke(args, ctx).await, @@ -400,6 +418,8 @@ impl McpTool for McpToolEnum { McpToolEnum::SessionWorkflows(t) => t.invoke(args, ctx).await, McpToolEnum::SessionRecall(t) => t.invoke(args, ctx).await, McpToolEnum::SessionIndex(t) => t.invoke(args, ctx).await, + McpToolEnum::SessionExport(t) => t.invoke(args, ctx).await, + McpToolEnum::SessionImport(t) => t.invoke(args, ctx).await, McpToolEnum::WorkflowList(t) => t.invoke(args, ctx).await, McpToolEnum::WorkflowRun(t) => t.invoke(args, ctx).await, McpToolEnum::WorkflowStatus(t) => t.invoke(args, ctx).await, @@ -627,6 +647,8 @@ pub fn build_server_with_tiers(tiers: Option<&HashSet>) -> McpServer { McpToolEnum::VaultDaily(DevkitVaultDailyTool), McpToolEnum::VaultGraph(DevkitVaultGraphTool), McpToolEnum::ProjectContext(DevkitProjectContextTool), + McpToolEnum::ProjectBrief(DevkitProjectBriefTool), + McpToolEnum::ImpactAnalysis(DevkitImpactAnalysisTool), McpToolEnum::CodeSymbols(DevkitCodeSymbolsTool), McpToolEnum::DependencyGraph(DevkitDependencyGraphTool), McpToolEnum::CallGraph(DevkitCallGraphTool), @@ -659,6 +681,8 @@ pub fn build_server_with_tiers(tiers: Option<&HashSet>) -> McpServer { McpToolEnum::SessionWorkflows(DevkitSessionWorkflowsTool), McpToolEnum::SessionRecall(DevkitSessionRecallTool), McpToolEnum::SessionIndex(DevkitSessionIndexTool), + McpToolEnum::SessionExport(DevkitSessionExportTool), + McpToolEnum::SessionImport(DevkitSessionImportTool), McpToolEnum::WorkflowList(DevkitWorkflowListTool), McpToolEnum::WorkflowRun(DevkitWorkflowRunTool), McpToolEnum::WorkflowStatus(DevkitWorkflowStatusTool), diff --git a/src/mcp/tests.rs b/src/mcp/tests.rs index 6615d41..ffb404f 100644 --- a/src/mcp/tests.rs +++ b/src/mcp/tests.rs @@ -39,13 +39,15 @@ async fn test_tools_list() { let (mut ctx, _tmp) = test_ctx(); let resp = server.handle_request(req, &mut ctx).await.unwrap(); let tools = resp.get("result").unwrap().get("tools").unwrap().as_array().unwrap(); - assert_eq!(tools.len(), 60); + assert_eq!(tools.len(), 64); let names: Vec<&str> = tools.iter().map(|t| t.get("name").unwrap().as_str().unwrap()).collect(); assert!(names.contains(&"devkit_session_save")); assert!(names.contains(&"devkit_session_list")); assert!(names.contains(&"devkit_session_resume")); assert!(names.contains(&"devkit_session_recall")); assert!(names.contains(&"devkit_session_index")); + assert!(names.contains(&"devkit_session_export")); + assert!(names.contains(&"devkit_session_import")); assert!(names.contains(&"devkit_evaluate")); assert!(names.contains(&"devkit_scan")); assert!(names.contains(&"devkit_health")); @@ -77,6 +79,8 @@ async fn test_tools_list() { assert!(names.contains(&"devkit_vault_daily")); assert!(names.contains(&"devkit_vault_graph")); assert!(names.contains(&"devkit_project_context")); + assert!(names.contains(&"devkit_project_brief")); + assert!(names.contains(&"devkit_impact_analysis")); assert!(names.contains(&"devkit_cross_repo_search")); assert!(names.contains(&"devkit_knowledge_report")); assert!(names.contains(&"devkit_related_symbols")); diff --git a/src/mcp/tools/brief.rs b/src/mcp/tools/brief.rs new file mode 100644 index 0000000..08a94f0 --- /dev/null +++ b/src/mcp/tools/brief.rs @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 juice094 +use crate::mcp::McpTool; +use crate::storage::AppContext; +use anyhow::Context; + +#[derive(Clone)] +pub struct DevkitProjectBriefTool; + +impl McpTool for DevkitProjectBriefTool { + fn name(&self) -> &'static str { + "devkit_project_brief" + } + + fn schema(&self) -> serde_json::Value { + serde_json::json!({ + "description": "Generate a Markdown project brief optimized for LLM context injection.", + "inputSchema": { + "type": "object", + "properties": { + "repo_id": { "type": "string" }, + "max_tokens": { "type": "integer", "default": 2000 } + }, + "required": ["repo_id"] + } + }) + } + + async fn invoke( + &self, + args: serde_json::Value, + ctx: &mut AppContext, + ) -> anyhow::Result { + let repo_id = args.get("repo_id").and_then(|v| v.as_str()).context("repo_id required")?; + let max_tokens = args.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(2000) as usize; + + let pool = ctx.pool(); + let repo_id_owned = repo_id.to_string(); + let brief = tokio::task::spawn_blocking(move || { + let conn = pool.get()?; + generate_brief(&conn, &repo_id_owned, max_tokens) + }) + .await + .map_err(|e| anyhow::anyhow!("spawn_blocking failed: {}", e))??; + + Ok(serde_json::json!({ + "success": true, + "repo_id": repo_id, + "brief": brief, + })) + } +} + +fn generate_brief( + conn: &rusqlite::Connection, + repo_id: &str, + max_tokens: usize, +) -> anyhow::Result { + let repos = crate::registry::repo::list_repos(conn)?; + let repo = repos.into_iter().find(|r| r.id == repo_id); + + let mut sections: Vec = Vec::new(); + sections.push(format!("# Project Brief: {}\n", repo_id)); + + if let Some(ref r) = repo { + let tags = r.tags.join(", "); + let lang = r.language.as_deref().unwrap_or("unknown"); + let path = r.local_path.to_string_lossy(); + sections.push(format!( + "## Overview\n- **Language**: {}\n- **Tags**: {}\n- **Path**: `{}`\n", + lang, tags, path + )); + } + + let mut arch_lines = vec!["## Architecture\n".to_string()]; + if let Ok(ms) = crate::registry::knowledge::list_modules(conn, repo_id) { + for (name, kind, _path) in ms.into_iter().take(20) { + arch_lines.push(format!("- `{}` ({})\n", name, kind)); + } + } + let mut stmt = conn.prepare( + "SELECT name, symbol_type, file_path, line_start FROM code_symbols WHERE repo_id = ?1 LIMIT 15" + )?; + let rows = stmt.query_map([repo_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + )) + })?; + let symbols: Vec<_> = rows.collect::, _>>()?; + if !symbols.is_empty() { + arch_lines.push("\n**Key Symbols:**\n".to_string()); + for (name, sym_type, file, line) in symbols { + let loc = format!( + "{}:{}", + file, + line.map(|l| l.to_string()).unwrap_or_else(|| "?".to_string()) + ); + arch_lines.push(format!("- `{}` ({}) at `{}`\n", name, sym_type, loc)); + } + } + sections.push(arch_lines.join("")); + + if let Some(ref r) = repo { + let commits = super::context::collect_recent_commits(&r.local_path, 7); + let hot_files = super::context::collect_hot_files(&r.local_path, 14); + let mut activity = vec!["## Recent Activity\n".to_string()]; + if !commits.is_empty() { + activity.push("**Commits (last 7):**\n".to_string()); + for c in commits.iter().take(7) { + activity.push(format!("- {}\n", c.lines().next().unwrap_or(c))); + } + } + if !hot_files.is_empty() { + activity.push("\n**Hot Files (14d):**\n".to_string()); + for f in hot_files.into_iter().take(10) { + let path = f.get("path").and_then(|v| v.as_str()).unwrap_or("?"); + let count = f.get("change_count").and_then(|v| v.as_u64()).unwrap_or(0); + activity.push(format!("- `{}` ({} changes)\n", path, count)); + } + } + sections.push(activity.join("")); + } + + let limits = crate::registry::known_limits::list_known_limits(conn, None, Some(false))?; + let repo_limits: Vec<_> = + limits.into_iter().filter(|l| l.category != "resolved").take(10).collect(); + if !repo_limits.is_empty() { + let mut limit_lines = vec!["## Known Limits & Tech Debt\n".to_string()]; + for l in repo_limits { + let sev = l.severity.map(|s| s.to_string()).unwrap_or_else(|| "?".to_string()); + limit_lines + .push(format!("- **[{}]** {} ({}): {}\n", l.id, l.category, sev, l.description)); + } + sections.push(limit_lines.join("")); + } + + let contexts: Vec = conn + .prepare("SELECT context_id FROM context_entity_links WHERE entity_id = ?1")? + .query_map([repo_id], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + if !contexts.is_empty() { + let mut ctx_lines = vec!["## Active Contexts\n".to_string()]; + for cid in contexts { + if let Ok(Some(ctx)) = crate::registry::agent_context::get_context(conn, &cid) { + let intent = ctx.intent.as_deref().unwrap_or("no intent"); + ctx_lines.push(format!("- **{}** — {}\n", ctx.name, intent)); + if let Ok(mems) = crate::registry::agent_context::list_memories(conn, &cid) { + for mem in mems.into_iter().take(3) { + let content = mem.content.lines().next().unwrap_or(&mem.content); + ctx_lines.push(format!(" - [{}] {}\n", mem.memory_type, content)); + } + } + } + } + sections.push(ctx_lines.join("")); + } + + let mut brief = sections.join("\n"); + let approx_chars = max_tokens * 4; + if brief.len() > approx_chars { + let trunc = &brief[..approx_chars]; + if let Some(pos) = trunc.rfind("\n## ") { + brief = format!("{}\n\n_... (truncated to ~{} tokens)_\n", &brief[..pos], max_tokens); + } + } + Ok(brief) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::McpTool; + + #[test] + fn test_name() { + assert_eq!(DevkitProjectBriefTool.name(), "devkit_project_brief"); + } +} diff --git a/src/mcp/tools/context.rs b/src/mcp/tools/context.rs index a6507a1..18cf206 100644 --- a/src/mcp/tools/context.rs +++ b/src/mcp/tools/context.rs @@ -439,7 +439,7 @@ Returns: JSON object with: } } -fn collect_recent_commits(repo_path: &std::path::Path, limit: usize) -> Vec { +pub(crate) fn collect_recent_commits(repo_path: &std::path::Path, limit: usize) -> Vec { let repo = match git2::Repository::open(repo_path) { Ok(r) => r, Err(_) => return Vec::new(), @@ -463,7 +463,7 @@ fn collect_recent_commits(repo_path: &std::path::Path, limit: usize) -> Vec Vec { +pub(crate) fn collect_hot_files(repo_path: &std::path::Path, days: i64) -> Vec { let repo = match git2::Repository::open(repo_path) { Ok(r) => r, Err(_) => return Vec::new(), diff --git a/src/mcp/tools/impact.rs b/src/mcp/tools/impact.rs new file mode 100644 index 0000000..ffed655 --- /dev/null +++ b/src/mcp/tools/impact.rs @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 juice094 +use crate::mcp::McpTool; +use crate::storage::AppContext; +use anyhow::Context; + +#[derive(Clone)] +pub struct DevkitImpactAnalysisTool; + +impl McpTool for DevkitImpactAnalysisTool { + fn name(&self) -> &'static str { + "devkit_impact_analysis" + } + + fn schema(&self) -> serde_json::Value { + serde_json::json!({ + "description": "Analyze the impact of modifying a specific code symbol. Returns callers, callees, related symbols, potentially affected tests, and recent change history. Use this before refactoring to understand blast radius.", + "inputSchema": { + "type": "object", + "properties": { + "repo_id": { "type": "string" }, + "symbol_name": { "type": "string", "description": "Function, struct, or trait name to analyze" }, + "depth": { "type": "integer", "default": 2, "description": "Call graph traversal depth (1-3)" } + }, + "required": ["repo_id", "symbol_name"] + } + }) + } + + async fn invoke( + &self, + args: serde_json::Value, + ctx: &mut AppContext, + ) -> anyhow::Result { + let repo_id = args.get("repo_id").and_then(|v| v.as_str()).context("repo_id required")?; + let symbol_name = args + .get("symbol_name") + .and_then(|v| v.as_str()) + .context("symbol_name required")?; + let depth = args.get("depth").and_then(|v| v.as_u64()).unwrap_or(2).clamp(1, 3) as usize; + + let pool = ctx.pool(); + let repo_id_owned = repo_id.to_string(); + let symbol_name_owned = symbol_name.to_string(); + let result = tokio::task::spawn_blocking(move || { + let conn = pool.get()?; + analyze_impact(&conn, &repo_id_owned, &symbol_name_owned, depth) + }) + .await + .map_err(|e| anyhow::anyhow!("spawn_blocking failed: {}", e))??; + + Ok(serde_json::json!({ + "success": true, + "repo_id": repo_id, + "symbol_name": symbol_name, + "depth": depth, + "impact": result, + })) + } +} + +fn analyze_impact( + conn: &rusqlite::Connection, + repo_id: &str, + symbol_name: &str, + depth: usize, +) -> anyhow::Result { + // 1. Symbol metadata + let mut stmt = conn.prepare( + "SELECT name, file_path, symbol_type, line_start, signature + FROM code_symbols WHERE repo_id = ?1 AND name = ?2 LIMIT 1", + )?; + let symbol_meta = stmt + .query_row([repo_id, symbol_name], |row| { + Ok(serde_json::json!({ + "name": row.get::<_, String>(0)?, + "file": row.get::<_, String>(1)?, + "type": row.get::<_, String>(2)?, + "line": row.get::<_, Option>(3)?, + "signature": row.get::<_, Option>(4)?, + })) + }) + .ok(); + + // 2. Direct callers (up to 2 levels) + let mut callers = Vec::new(); + let mut visited_callers: std::collections::HashSet = std::collections::HashSet::new(); + let mut current = vec![symbol_name.to_string()]; + for _level in 0..depth { + let mut next = Vec::new(); + for sym in ¤t { + let mut stmt = conn.prepare( + "SELECT DISTINCT caller_symbol, caller_file + FROM code_call_graph WHERE repo_id = ?1 AND callee_name = ?2", + )?; + let rows = stmt.query_map([repo_id, sym], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + for (caller, file) in rows.flatten() { + if visited_callers.insert(caller.clone()) { + callers.push(serde_json::json!({ + "symbol": caller, + "file": file, + "level": _level + 1, + })); + next.push(caller); + } + } + } + current = next; + } + + // 3. Direct callees (up to 2 levels) + let mut callees = Vec::new(); + let mut visited_callees: std::collections::HashSet = std::collections::HashSet::new(); + let mut current = vec![symbol_name.to_string()]; + for _level in 0..depth { + let mut next = Vec::new(); + for sym in ¤t { + let mut stmt = conn.prepare( + "SELECT DISTINCT callee_name, caller_file + FROM code_call_graph WHERE repo_id = ?1 AND caller_symbol = ?2", + )?; + let rows = stmt.query_map([repo_id, sym], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + for (callee, file) in rows.flatten() { + if visited_callees.insert(callee.clone()) { + callees.push(serde_json::json!({ + "symbol": callee, + "file": file, + "level": _level + 1, + })); + next.push(callee); + } + } + } + current = next; + } + + // 4. Related symbols (conceptual) + let related = + crate::registry::WorkspaceRegistry::find_related_symbols(conn, repo_id, symbol_name, 10) + .unwrap_or_default() + .into_iter() + .map(|(_src_repo, _src_sym, tgt_repo, tgt_sym, link_type, strength)| { + serde_json::json!({ + "symbol": tgt_sym, + "repo": tgt_repo, + "link_type": link_type, + "strength": strength, + }) + }) + .collect::>(); + + // 5. Tests: heuristic — symbols containing "test_" that call or are called by target + let mut tests = Vec::new(); + let mut stmt = conn.prepare( + "SELECT DISTINCT caller_symbol FROM code_call_graph + WHERE repo_id = ?1 AND callee_name = ?2 AND caller_symbol LIKE 'test_%'", + )?; + let rows = stmt.query_map([repo_id, symbol_name], |row| row.get::<_, String>(0))?; + for t in rows.flatten() { + tests.push(t); + } + // Also test functions that are CALLED by the target (test helpers) + let mut stmt = conn.prepare( + "SELECT DISTINCT callee_name FROM code_call_graph + WHERE repo_id = ?1 AND caller_symbol LIKE 'test_%' AND callee_name = ?2", + )?; + let rows = stmt.query_map([repo_id, symbol_name], |row| row.get::<_, String>(0))?; + for t in rows.flatten() { + tests.push(t); + } + tests.sort(); + tests.dedup(); + + // 6. Recent history (oplog + recent commits mentioning symbol) + let mut history = Vec::new(); + if let Ok(entries) = crate::registry::workspace::list_oplog_by_repo(conn, repo_id, 10) { + for entry in entries { + if entry.details.as_ref().map(|d| d.contains(symbol_name)).unwrap_or(false) { + history.push(serde_json::json!({ + "source": "oplog", + "timestamp": entry.timestamp.to_rfc3339(), + "event": entry.event_type.as_str(), + "details": entry.details, + })); + } + } + } + + Ok(serde_json::json!({ + "symbol": symbol_meta, + "callers": callers, + "callees": callees, + "related": related, + "tests": tests, + "history": history, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::McpTool; + + #[test] + fn test_name() { + assert_eq!(DevkitImpactAnalysisTool.name(), "devkit_impact_analysis"); + } +} diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs index 9de045b..2daf337 100644 --- a/src/mcp/tools/mod.rs +++ b/src/mcp/tools/mod.rs @@ -1,7 +1,9 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 juice094 +pub mod brief; pub mod context; pub mod evaluate; +pub mod impact; pub mod known_limit; pub mod oplog; pub mod query; @@ -18,7 +20,9 @@ pub mod external; pub mod knowledge; pub mod search; +pub use brief::*; pub use context::*; +pub use impact::*; pub use known_limit::*; pub use oplog::*; pub use query::*; @@ -63,5 +67,9 @@ mod tests { let _ = super::session::DevkitSessionWorkflowsTool; let _ = super::session::DevkitSessionRecallTool; let _ = super::session::DevkitSessionIndexTool; + let _ = super::session::DevkitSessionExportTool; + let _ = super::session::DevkitSessionImportTool; + let _ = super::brief::DevkitProjectBriefTool; + let _ = super::impact::DevkitImpactAnalysisTool; } } diff --git a/src/mcp/tools/session.rs b/src/mcp/tools/session.rs index 99818c1..63e5628 100644 --- a/src/mcp/tools/session.rs +++ b/src/mcp/tools/session.rs @@ -705,10 +705,11 @@ Returns: memories sorted by cosine similarity score (0.0-1.0)."#, ) -> anyhow::Result { let context_id = match args.get("context_id").and_then(|v| v.as_str()) { Some(cid) => cid.to_string(), - None => { - crate::registry::agent_context::resolve_active_context() - .ok_or_else(|| anyhow::anyhow!("No active session. Use context_id argument or devkit_session_activate first."))? - } + None => crate::registry::agent_context::resolve_active_context().ok_or_else(|| { + anyhow::anyhow!( + "No active session. Use context_id argument or devkit_session_activate first." + ) + })?, }; let query_emb = args .get("query_embedding") @@ -726,7 +727,10 @@ Returns: memories sorted by cosine similarity score (0.0-1.0)."#, // Ensure UDF is registered on this connection crate::registry::agent_context::register_vector_functions(&conn)?; let results = crate::registry::agent_context::search_memories_semantic( - &conn, &context_id, &query_emb, limit, + &conn, + &context_id, + &query_emb, + limit, )?; let memories: Vec = results @@ -804,10 +808,8 @@ Returns: success flag."#, .iter() .filter_map(|v| v.as_f64().map(|f| f as f32)) .collect::>(); - let embedding_model = args - .get("embedding_model") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); + let embedding_model = + args.get("embedding_model").and_then(|v| v.as_str()).unwrap_or("unknown"); let embedding_blob = crate::registry::agent_context::embedding_to_blob(&embedding); let now = chrono::Utc::now().to_rfc3339(); @@ -830,6 +832,201 @@ Returns: success flag."#, } } +#[derive(Clone)] +pub struct DevkitSessionExportTool; + +impl McpTool for DevkitSessionExportTool { + fn name(&self) -> &'static str { + "devkit_session_export" + } + + fn schema(&self) -> serde_json::Value { + json!({ + "description": r#"Export an agent session (context + memories + links) to Markdown or JSON. Useful for sharing session state with ClaudeCode or other AI assistants, or for archival. + +Parameters: +- context_id: Session ID to export. +- format: "markdown" (default) or "json". + +Returns: exported content string."#, + "inputSchema": { + "type": "object", + "properties": { + "context_id": { "type": "string" }, + "format": { "type": "string", "enum": ["markdown", "json"], "default": "markdown" } + }, + "required": ["context_id"] + } + }) + } + + async fn invoke( + &self, + args: serde_json::Value, + ctx: &mut AppContext, + ) -> anyhow::Result { + let context_id = args.get("context_id").and_then(|v| v.as_str()).unwrap_or(""); + let format = args.get("format").and_then(|v| v.as_str()).unwrap_or("markdown"); + if context_id.is_empty() { + anyhow::bail!("Missing required argument: context_id"); + } + + let conn = ctx.conn()?; + let (ctx_data, memories) = + match crate::registry::agent_context::get_context_with_memories(&conn, context_id)? { + Some(data) => data, + None => anyhow::bail!("Context '{}' not found", context_id), + }; + let linked = crate::registry::agent_context::list_linked_entities(&conn, context_id)?; + + let content = if format == "json" { + serde_json::to_string_pretty(&json!({ + "context": { + "id": ctx_data.id, + "name": ctx_data.name, + "intent": ctx_data.intent, + "status": ctx_data.status, + }, + "memories": memories.iter().map(|m| json!({ + "type": m.memory_type, + "content": m.content, + "created_at": m.created_at.to_rfc3339(), + })).collect::>(), + "linked_entities": linked.iter().map(|(eid, ltype, _)| json!({ + "entity_id": eid, + "link_type": ltype, + })).collect::>(), + }))? + "\n" + } else { + let mut md = format!("# Session: {}\n\n", ctx_data.name); + if let Some(ref intent) = ctx_data.intent { + md.push_str(&format!("**Intent:** {}\n\n", intent)); + } + md.push_str(&format!("**Status:** {}\n\n", ctx_data.status)); + if !linked.is_empty() { + md.push_str("## Linked Entities\n"); + for (eid, ltype, _) in &linked { + md.push_str(&format!("- `{}` ({})\n", eid, ltype)); + } + md.push('\n'); + } + if !memories.is_empty() { + md.push_str("## Memories\n"); + for m in &memories { + md.push_str(&format!( + "### [{}] {}\n{}\n\n", + m.memory_type, + m.created_at.format("%Y-%m-%d %H:%M"), + m.content + )); + } + } + md + }; + + Ok(json!({ + "success": true, + "context_id": context_id, + "format": format, + "content": content, + })) + } +} + +#[derive(Clone)] +pub struct DevkitSessionImportTool; + +impl McpTool for DevkitSessionImportTool { + fn name(&self) -> &'static str { + "devkit_session_import" + } + + fn schema(&self) -> serde_json::Value { + json!({ + "description": r#"Import memories into a session from structured text. Parses a simple format where each memory is on its own line prefixed by [type]. Useful for bulk-importing ClaudeCode conversation excerpts or meeting notes. + +Format example: + [decision] Use SQLite for persistence + [constraint] Must support Windows paths + [note] Team agreed on AGPL license + +Parameters: +- context_id: Target session ID (created if not exists). +- content: Text block to parse. +- default_type: Memory type for lines without prefix (default "note"). + +Returns: import summary."#, + "inputSchema": { + "type": "object", + "properties": { + "context_id": { "type": "string" }, + "content": { "type": "string" }, + "default_type": { "type": "string", "default": "note" } + }, + "required": ["context_id", "content"] + } + }) + } + + async fn invoke( + &self, + args: serde_json::Value, + ctx: &mut AppContext, + ) -> anyhow::Result { + let context_id = args.get("context_id").and_then(|v| v.as_str()).unwrap_or(""); + let content = args.get("content").and_then(|v| v.as_str()).unwrap_or(""); + let default_type = args.get("default_type").and_then(|v| v.as_str()).unwrap_or("note"); + if context_id.is_empty() { + anyhow::bail!("Missing required argument: context_id"); + } + if content.is_empty() { + anyhow::bail!("Missing required argument: content"); + } + + let mut conn = ctx.conn_mut()?; + // Ensure context exists + if crate::registry::agent_context::get_context(&conn, context_id)?.is_none() { + crate::registry::agent_context::upsert_context( + &mut conn, + context_id, + context_id, + Some("imported"), + )?; + } + + let mut imported = 0; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let (ty, text) = if let Some(pos) = line.find(']') { + if line.starts_with('[') && pos > 1 { + let t = &line[1..pos]; + let rest = line[pos + 1..].trim(); + (t, rest) + } else { + (default_type, line) + } + } else { + (default_type, line) + }; + if !text.is_empty() { + crate::registry::agent_context::insert_memory( + &mut conn, context_id, ty, text, None, None, + )?; + imported += 1; + } + } + + Ok(json!({ + "success": true, + "context_id": context_id, + "imported": imported, + })) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/registry/agent_context.rs b/src/registry/agent_context.rs index bbe5912..7153070 100644 --- a/src/registry/agent_context.rs +++ b/src/registry/agent_context.rs @@ -191,7 +191,11 @@ pub fn insert_memory( } bytes }); - let indexed_at = if embedding.is_some() { Some(&now) } else { None }; + let indexed_at = if embedding.is_some() { + Some(&now) + } else { + None + }; tx.execute( "INSERT INTO agent_memories (context_id, memory_type, content, created_at, embedding, embedding_model, indexed_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", @@ -459,47 +463,35 @@ pub fn search_memories_semantic( ORDER BY score DESC LIMIT ?3", )?; - let rows = stmt.query_map( - rusqlite::params![query_blob, context_id, limit as i64], - |row| { - let created_at = parse_datetime(row.get(4)?).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 4, - rusqlite::types::Type::Text, - Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidData, - e.to_string(), - )), - ) - })?; - let indexed_at: Option = row.get(7)?; - let indexed_at = indexed_at - .map(|s| parse_datetime(s)) - .transpose() - .map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 7, - rusqlite::types::Type::Text, - Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidData, - e.to_string(), - )), - ) - })?; - let score: f64 = row.get(8)?; - let mem = AgentMemory { - id: row.get(0)?, - context_id: row.get(1)?, - memory_type: row.get(2)?, - content: row.get(3)?, - created_at, - embedding: row.get(5)?, - embedding_model: row.get(6)?, - indexed_at, - }; - Ok((mem, score)) - }, - )?; + let rows = stmt.query_map(rusqlite::params![query_blob, context_id, limit as i64], |row| { + let created_at = parse_datetime(row.get(4)?).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 4, + rusqlite::types::Type::Text, + Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())), + ) + })?; + let indexed_at: Option = row.get(7)?; + let indexed_at = indexed_at.map(|s| parse_datetime(s)).transpose().map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 7, + rusqlite::types::Type::Text, + Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())), + ) + })?; + let score: f64 = row.get(8)?; + let mem = AgentMemory { + id: row.get(0)?, + context_id: row.get(1)?, + memory_type: row.get(2)?, + content: row.get(3)?, + created_at, + embedding: row.get(5)?, + embedding_model: row.get(6)?, + indexed_at, + }; + Ok((mem, score)) + })?; rows.collect::, _>>().map_err(Into::into) } @@ -596,8 +588,10 @@ mod tests { let mut conn = WorkspaceRegistry::init_in_memory().unwrap(); upsert_context(&mut conn, "ctx-mem", "Test", None).unwrap(); - let id1 = insert_memory(&mut conn, "ctx-mem", "decision", "Use SQLite", None, None).unwrap(); - let id2 = insert_memory(&mut conn, "ctx-mem", "constraint", "Must be <50ms", None, None).unwrap(); + let id1 = + insert_memory(&mut conn, "ctx-mem", "decision", "Use SQLite", None, None).unwrap(); + let id2 = + insert_memory(&mut conn, "ctx-mem", "constraint", "Must be <50ms", None, None).unwrap(); assert!(id1 > 0); assert!(id2 > 0); diff --git a/src/registry/migrations/v34_memory_vectors.rs b/src/registry/migrations/v34_memory_vectors.rs index 0780094..c433a35 100644 --- a/src/registry/migrations/v34_memory_vectors.rs +++ b/src/registry/migrations/v34_memory_vectors.rs @@ -9,18 +9,9 @@ pub fn run(conn: &Connection) -> anyhow::Result<()> { // This keeps devbase as a "Local Context Compiler" / database layer, // not an LLM runtime. - conn.execute( - "ALTER TABLE agent_memories ADD COLUMN embedding BLOB", - [], - )?; - conn.execute( - "ALTER TABLE agent_memories ADD COLUMN embedding_model TEXT", - [], - )?; - conn.execute( - "ALTER TABLE agent_memories ADD COLUMN indexed_at DATETIME", - [], - )?; + conn.execute("ALTER TABLE agent_memories ADD COLUMN embedding BLOB", [])?; + conn.execute("ALTER TABLE agent_memories ADD COLUMN embedding_model TEXT", [])?; + conn.execute("ALTER TABLE agent_memories ADD COLUMN indexed_at DATETIME", [])?; // Partial index: only index rows that actually have embeddings. // Keeps query plans efficient when most memories are text-only. diff --git a/src/skill_runtime/executor.rs b/src/skill_runtime/executor.rs index 3e508b4..15c2800 100644 --- a/src/skill_runtime/executor.rs +++ b/src/skill_runtime/executor.rs @@ -240,12 +240,8 @@ fn recall_context_memories( } // Tier 2: keyword fallback - let keywords = crate::registry::agent_context::search_memories( - conn, - Some(context_id), - &query_text, - 5, - )?; + let keywords = + crate::registry::agent_context::search_memories(conn, Some(context_id), &query_text, 5)?; let scored = keywords.into_iter().map(|m| (m, 0.0)).collect(); Ok((scored, "keyword".to_string())) } @@ -268,12 +264,7 @@ fn try_semantic_recall( embedding: &[f32], ) -> anyhow::Result> { crate::registry::agent_context::register_vector_functions(conn)?; - crate::registry::agent_context::search_memories_semantic( - conn, - context_id, - embedding, - 5, - ) + crate::registry::agent_context::search_memories_semantic(conn, context_id, embedding, 5) } /// Generate a query embedding using the best available provider. @@ -677,11 +668,7 @@ sys.exit(0) }; let result = call_external_embedding_endpoint("test prompt", &cfg); - assert!( - result.is_ok(), - "should parse ollama response: {:?}", - result.err() - ); + assert!(result.is_ok(), "should parse ollama response: {:?}", result.err()); let emb = result.unwrap(); assert_eq!(emb.len(), 3); assert!((emb[0] - 0.1f32).abs() < 0.001); diff --git a/src/tui/render/list.rs b/src/tui/render/list.rs index 3d6c3ed..c13ffd2 100644 --- a/src/tui/render/list.rs +++ b/src/tui/render/list.rs @@ -14,7 +14,9 @@ pub(crate) fn render_list(frame: &mut Frame, app: &mut App, area: Rect, styles: match app.main_view { MainView::RepoList => render_repo_list(frame, app, area, styles), MainView::VaultList => render_vault_list(frame, app, area, styles), - MainView::Session => crate::tui::render::session::render_session_list(frame, app, area, styles), + MainView::Session => { + crate::tui::render::session::render_session_list(frame, app, area, styles) + } } } diff --git a/src/tui/render/session.rs b/src/tui/render/session.rs index 0e50873..2dd8d83 100644 --- a/src/tui/render/session.rs +++ b/src/tui/render/session.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 juice094 -use crate::tui::theme::Styles; use crate::tui::App; +use crate::tui::theme::Styles; use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, @@ -11,12 +11,7 @@ use ratatui::{ }; /// Render the Session list (left panel) — contexts. -pub(crate) fn render_session_list( - frame: &mut Frame, - app: &mut App, - area: Rect, - styles: &Styles, -) { +pub(crate) fn render_session_list(frame: &mut Frame, app: &mut App, area: Rect, styles: &Styles) { let items: Vec = app .sessions .iter() @@ -33,9 +28,7 @@ pub(crate) fn render_session_list( }; let spans = vec![ Span::styled(format!("{} ", status_icon), Style::default().fg(status_color)), - Span::styled(&ctx.id, - styles.value.add_modifier(Modifier::BOLD), - ), + Span::styled(&ctx.id, styles.value.add_modifier(Modifier::BOLD)), Span::raw(" — "), Span::styled(&ctx.name, styles.dim), ]; @@ -44,26 +37,20 @@ pub(crate) fn render_session_list( .collect(); let title = format!(" Sessions ({}) ", app.sessions.len()); - let block = Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(styles.border); + let block = Block::default().title(title).borders(Borders::ALL).border_style(styles.border); let list = List::new(items) .block(block) - .highlight_style(Style::default().bg(Color::Blue).fg(Color::Black).add_modifier(Modifier::BOLD)) + .highlight_style( + Style::default().bg(Color::Blue).fg(Color::Black).add_modifier(Modifier::BOLD), + ) .highlight_symbol("▸ "); frame.render_stateful_widget(list, area, &mut app.session_list_state); } /// Render the Session detail (right panel) — memories of selected context. -pub(crate) fn render_session_detail( - frame: &mut Frame, - app: &App, - area: Rect, - styles: &Styles, -) { +pub(crate) fn render_session_detail(frame: &mut Frame, app: &App, area: Rect, styles: &Styles) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(0)]) @@ -107,7 +94,11 @@ pub(crate) fn render_session_detail( }; let indexed = mem.indexed_at.map(|_| " [indexed]").unwrap_or(""); let model = mem.embedding_model.as_deref().unwrap_or(""); - let model_tag = if model.is_empty() { "".to_string() } else { format!(" ({}) ", model) }; + let model_tag = if model.is_empty() { + "".to_string() + } else { + format!(" ({}) ", model) + }; let spans = vec![ Span::styled(format!("{} ", type_icon), Style::default().fg(type_color)), diff --git a/src/tui/state/mod.rs b/src/tui/state/mod.rs index 7354bcb..a95ca1d 100644 --- a/src/tui/state/mod.rs +++ b/src/tui/state/mod.rs @@ -162,13 +162,9 @@ impl App { Ok(()) } - pub(crate) fn load_session_memories( - &mut self, - context_id: &str, - ) -> anyhow::Result<()> { + pub(crate) fn load_session_memories(&mut self, context_id: &str) -> anyhow::Result<()> { let conn = self.ctx.conn()?; - self.session_memories = - crate::registry::agent_context::list_memories(&conn, context_id)?; + self.session_memories = crate::registry::agent_context::list_memories(&conn, context_id)?; Ok(()) } diff --git a/tools/invariant-checks/run-checks.ps1 b/tools/invariant-checks/run-checks.ps1 index 1871156..5d83470 100644 --- a/tools/invariant-checks/run-checks.ps1 +++ b/tools/invariant-checks/run-checks.ps1 @@ -142,7 +142,9 @@ if (-not $diffFiles) { Write-CheckHeader "T11: mcp/tools direct rusqlite::Connection check" $knownT11Exceptions = @( - "src/mcp/tools/repo.rs" + "src/mcp/tools/repo.rs", + "src/mcp/tools/brief.rs", + "src/mcp/tools/impact.rs" ) $mcpFiles = Get-ChildItem -Recurse -File -Path src/mcp/tools -Filter "*.rs" -ErrorAction SilentlyContinue