├── .github └── workflows │ ├── ci.yml │ ├── claude.yml │ └── release.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .nvmrc ├── .prettierrc ├── .versionrc ├── .xrelease.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── agents ├── critic │ ├── .python-version │ ├── README.md │ ├── agent.py │ ├── critic.egg-info │ │ ├── PKG-INFO │ │ ├── SOURCES.txt │ │ ├── dependency_links.txt │ │ ├── requires.txt │ │ └── top_level.txt │ ├── fastagent.config.template.yaml │ ├── fastagent.secrets.template.yaml │ ├── pyproject.toml │ └── uv.lock └── summarize │ ├── .python-version │ ├── README.md │ ├── agent.py │ ├── fastagent.config.template.yaml │ ├── fastagent.secrets.template.yaml │ ├── pyproject.toml │ └── uv.lock ├── commitlint.config.js ├── docs ├── INSTALL_GUIDE.md └── OVERVIEW.md ├── eslint.config.js ├── logs └── logs.md ├── media ├── png │ ├── code_loops_icon.png │ ├── code_loops_icon_transparent_dark.png │ ├── code_loops_logo_transparent_dark.png │ └── codeloops_banner.png └── svg │ ├── code_loops_icon.svg │ ├── code_loops_icon_transparent_dark.svg │ ├── code_loops_logo_transparent_dark.svg │ └── codeloops_banner.svg ├── package-lock.json ├── package.json ├── scripts ├── migrations │ └── migrate021_030.ts ├── setup.sh └── test-quickstart.sh ├── src ├── agents │ ├── Actor.ts │ ├── Critic.ts │ └── Summarize.ts ├── config.ts ├── engine │ ├── ActorCriticEngine.ts │ ├── KnowledgeGraph.delete.test.ts │ ├── KnowledgeGraph.test.ts │ └── KnowledgeGraph.ts ├── index.ts ├── logger.ts ├── server │ ├── http.ts │ ├── index.ts │ ├── stdio.ts │ └── tools.ts └── utils │ ├── fun.ts │ ├── git.test.ts │ ├── git.ts │ ├── project.test.ts │ └── project.ts └── tsconfig.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Build & Test 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x, 20.x, 22.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm install 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 1 31 | 32 | - name: Run Claude Code 33 | id: claude 34 | uses: anthropics/claude-code-action@beta 35 | with: 36 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 37 | 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Rename this to whatever you want to call the workflow, or leave as is 2 | name: Release 3 | 4 | # Manual trigger 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | version_bump: 9 | description: 'Version bump type' 10 | required: true 11 | default: 'patch' 12 | type: choice 13 | options: 14 | - patch 15 | - minor 16 | - major 17 | 18 | # For automatic trigger, use this instead: 19 | # on: 20 | # push: 21 | # branches: [main] 22 | # # Or trigger from another workflow: 23 | # # workflow_call: 24 | # # secrets: 25 | # # NPM_TOKEN: 26 | # # required: true 27 | 28 | jobs: 29 | release: 30 | runs-on: ubuntu-latest 31 | permissions: 32 | contents: write # Required for creating releases and tags 33 | pull-requests: write # Required if you want to create PRs 34 | steps: 35 | - uses: actions/checkout@v4 36 | with: 37 | fetch-depth: 0 # Important for correct version calculation 38 | 39 | - name: Setup Node.js 40 | uses: actions/setup-node@v4 41 | with: 42 | node-version: '22' 43 | # node-version-file: '.nvmrc' # or use a .nvmrc file instead 44 | registry-url: 'https://registry.npmjs.org' 45 | 46 | # Configure Git user - if you want to use a different user, or leave as is to use the default git bot user 47 | - name: Configure Git 48 | run: | 49 | git config --global user.name 'github-actions[bot]' 50 | git config --global user.email 'github-actions[bot]@users.noreply.github.com' 51 | 52 | # xrelease will: 53 | # 1. Update version in package.json 54 | # 2. Generate/update CHANGELOG.md 55 | # 3. Commit these changes with message "chore(release): x.y.z" 56 | # 4. Create and push git tag vx.y.z 57 | # 5. Create GitHub release from the changelog 58 | - name: Create Release 59 | run: npx xrelease create --bump ${{ inputs.version_bump }} 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | data/ 4 | notes/drafts 5 | agents/**/fastagent.secrets.yaml 6 | agents/**/fastagent.config.yaml 7 | agents/**/fastagent.jsonl 8 | logs/*.log 9 | logs/*.log* 10 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit $1 -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx -y tsc --noEmit 2 | npx -y eslint 3 | prettier --write --ignore-unknown -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "arrowParens": "always", 5 | "printWidth": 100, 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /.versionrc: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { 4 | "type": "feat", 5 | "section": "Features" 6 | }, 7 | { 8 | "type": "fix", 9 | "section": "Bug Fixes" 10 | }, 11 | { 12 | "type": "chore", 13 | "hidden": true 14 | }, 15 | { 16 | "type": "docs", 17 | "section": "Documentation" 18 | }, 19 | { 20 | "type": "style", 21 | "hidden": true 22 | }, 23 | { 24 | "type": "refactor", 25 | "section": "Code Refactoring" 26 | }, 27 | { 28 | "type": "perf", 29 | "section": "Performance Improvements" 30 | }, 31 | { 32 | "type": "test", 33 | "hidden": true 34 | } 35 | ], 36 | "commitUrlFormat": "{{host}}/{{owner}}/{{repository}}/commit/{{hash}}", 37 | "compareUrlFormat": "{{host}}/{{owner}}/{{repository}}/compare/{{previousTag}}...{{currentTag}}", 38 | "issueUrlFormat": "{{host}}/{{owner}}/{{repository}}/issues/{{id}}", 39 | "userUrlFormat": "{{host}}/{{user}}", 40 | "releaseCommitMessageFormat": "chore(release): {{currentTag}}" 41 | } -------------------------------------------------------------------------------- /.xrelease.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | release: 3 | # Branch configuration 4 | branch: main 5 | 6 | # Version bump type 7 | defaultBump: patch 8 | 9 | # Version file updates 10 | version: 11 | files: 12 | - path: 'package.json' 13 | pattern: "\"version\":\\s*\"(?[^\"]+)\"" 14 | template: '"version": "${version}"' 15 | 16 | # Changelog configuration 17 | changelog: 18 | enabled: true 19 | template: conventional 20 | 21 | # Pre-release checks 22 | # no linting, tests or artifacts are generated yet 23 | # checks: 24 | # - type: lint 25 | # command: 'npm run lint' 26 | # - type: test 27 | # command: 'npm test' 28 | # - type: build 29 | # command: 'npm run build' 30 | 31 | # Release actions actions 32 | actions: 33 | - type: git-tag 34 | - type: commit-push 35 | - type: github-release 36 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.5.1] - 2025-06-06 4 | 5 | * feat: add diff control options to resume command (#43) 6 | * chore: release v0.5.0 7 | 8 | ## [0.5.0] - 2025-06-06 9 | 10 | * feat: add HTTP server support with modular transport architecture (#41) 11 | * feat: add safe soft delete functionality for knowledge graph nodes 12 | * chore: release v0.4.0 13 | 14 | ## [0.4.0] - 2025-06-05 15 | 16 | * test: add comprehensive unit tests for git utility functions 17 | * refactor: extract git diff logic to dedicated utility module 18 | * feat: enhance diff tracking with automatic git diff generation 19 | * feat: add diff tracking to knowledge graph nodes 20 | * Add Claude PR Assistant workflow 21 | * feat: add brand assets and logo files to media directory 22 | * chore: release v0.3.8 23 | 24 | ## [0.3.8] - 2025-05-28 25 | 26 | * chore: switch display to false in template config 27 | * chore: release v0.3.7 28 | 29 | ## [0.3.7] - 2025-05-26 30 | 31 | * fix: json output and artifacts schema 32 | * chore: release v0.3.6 33 | 34 | ## [0.3.6] - 2025-05-22 35 | 36 | * chore: update eslint config 37 | * chore: move lint to pre-commit 38 | * chore: remove dup config 39 | * feat: add eslint 40 | * chore: general cleanup 41 | * chore: release v0.3.5 42 | 43 | ## [0.3.5] - 2025-05-21 44 | 45 | * fix: chatty summarize response 46 | * chore: release v0.3.4 47 | 48 | ## [0.3.4] - 2025-05-19 49 | 50 | * feat: implement initial fix and enhanced logging 51 | * chore: release v0.3.3 52 | 53 | ## [0.3.3] - 2025-05-17 54 | 55 | * feat: silence test logs 56 | * chore: update readme 57 | * chore: release v0.3.2 58 | 59 | ## [0.3.2] - 2025-05-16 60 | 61 | * chore: fix unit test 62 | * feat: add get node tool and remove project from getNode 63 | * feat: iniitial branch label removal 64 | * chore: release v0.3.1 65 | 66 | ## [0.3.1] - 2025-05-16 67 | 68 | * refactor: simplify export functionality by removing filterTag 69 | * chore: release v0.3.0 70 | 71 | ## [0.3.0] - 2025-05-16 72 | 73 | * chore: remove next steps 74 | * fix: list branches 75 | * feat: enhance KnowledgeGraphManager with async operations 76 | * chore: remove artifacts as individual 77 | * chore: further remove un-needed config 78 | * refactor: improve project context handling in actor-critic workflow 79 | * refactor: migrate to TypeScript with strict type checking and project context 80 | * refactor: rename export_knowledge_graph tool to export for better clarity 81 | * refactor: rename export_plan to export_knowledge_graph and add limit option 82 | * refactor: centralize project loading logic and add per-project logger contexts 83 | * refactor: remove RevisionCounter and simplify critic review logic 84 | * refactor: rename loadProject to tryLoadProject and add unit tests 85 | * chore: remove technical overview 86 | * refactor: rename selectedProject variable to activeProject for clarity 87 | * refactor: migrate to unified NDJSON format and enhance logging with pino-roll 88 | * chore: remove notes 89 | * feat: implement knowledge graph persistence redesign with NDJSON and explicit project context 90 | * refactor: replace console logging with logger usage across multiple files and delete todos.md file 91 | * feat: add logger 92 | * feat: implement project context switching to support multiple concurrent projects 93 | * chore: remove needs more from input schema 94 | * chore: update think descriptioon 95 | * chore: release v0.2.1 96 | 97 | ## [0.2.1] - 2025-05-10 98 | 99 | * chore: update prompt in readme 100 | * feat: add detailed install guide 101 | * chore: minor updates to next stesp 102 | * feat: rework readme 103 | * chore: remove cli.js 104 | * feat: add iniital quickstart scripts 105 | * chore: rename workflow 106 | * feat: add basic ci action 107 | * chore: release v0.2.0 108 | 109 | ## [0.2.0] - 2025-05-09 110 | 111 | * chore: add link to article and bannger img 112 | * feat: add initial rebrand 113 | * chore: add project tool docs 114 | * refactor: document critic_review tool as manual intervention 115 | * refactor: improve type safety and standardize knowledge graph structures 116 | * chore: remove summarize init 117 | * fix: switch projects call 118 | * refactor: improve file operations API and maintain backward compatibility 119 | * chore: release v0.1.0 120 | 121 | ## [0.1.0] - 2025-05-07 122 | 123 | * feat: fix import and refactor structure 124 | * feat: add kg unit tests 125 | * feat: refactor summarization logic out of knowledge graph 126 | * chore: add vitest 127 | * feat: add initial fix 128 | * chore: release v0.0.2 129 | 130 | ## [0.0.2] - 2025-05-04 131 | 132 | * chore: slight tweak to readme 133 | * chore: add more quickstart refinements 134 | * chore: adds uv installation docs link 135 | * feat: add quickstart docs 136 | * chore: update configs and readme quickstart draft 137 | * chore: release v0.0.1 138 | 139 | ## [0.0.1] - 2025-05-04 140 | 141 | * chore: update version and add tidy agent next steps 142 | * chore: format via prettier 143 | * feat: add release tooling 144 | * chore: update next steps 145 | * fix: add summary agent deps 146 | * feat: use actor critic to create summarize agent 147 | * feat: init uv 148 | * fix: dirname 149 | * feat: add exec critic python agent 150 | * chore: add execa 151 | * fix: nvm rc file 152 | * chore: add ignore config files 153 | * feat: add actor agent instructions 154 | * feat: add blank critic agent 155 | * chore: update readme 156 | * chore: rename kg file 157 | * feat: add default kg file 158 | * feat: add basic guards 159 | * feat: update thought description 160 | * chore: update readme 161 | * chore: update readme 162 | * chore: update readme 163 | * chore: clean up list 164 | * chore: update title 165 | * chore: add next steps 166 | * chore: add readme 167 | * feat: refactor actor critic engine 168 | * chore: add running comment 169 | * fix: types and format 170 | * feat: initial commit 171 | * Initial commit 172 | 173 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Mat Silva 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![CodeLoops](media/svg/codeloops_banner.svg) 2 | 3 | # CodeLoops: Enabling Coding Agent Autonomy 4 | 5 | CodeLoops is currently an experimental system, taking a different approach to help bring us closer to the holy grail of software development: fully autonomous coding agents. 6 | 7 | Inspired by the actor-critic model from Max Bennett’s _A Brief History of Intelligence_, CodeLoops aims to tackle the challenge of AI Agent “code slop”: messy, error-prone output that forgets APIs and drifts from project goals. By integrating with your existing agent as an MCP server, it delivers iterative feedback and persistent context, empowering your agent to work independently in auto mode while staying aligned with your vision. 8 | 9 | > **Note**: CodeLoops is in early development. Expect active updates. Back up your data and monitor API costs for premium models. 10 | 11 | Learn more by: 12 | 13 | - [reading the announcement](https://bytes.silvabyte.com/improving-coding-agents-an-early-look-at-codeloops-for-building-more-reliable-software/). 14 | - [checking out the overview](./docs/OVERVIEW.md). 15 | 16 | ## Why CodeLoops? 17 | 18 | AI coding agents promise to revolutionize development but suck at autonomy in complex projects. They suffer from memory gaps, context lapses, and a lack of guidance, producing unreliable code that requires constant manual fixes. CodeLoops unlocks their potential by providing: 19 | 20 | - **Iterative Feedback**: An actor-critic system refines your agent’s decisions in real time, guiding it toward precise, high-quality output. 21 | - **Knowledge Graph**: Stores context and feedback, ensuring your agent remembers APIs and project goals across sessions. 22 | - **Seamless Integration**: Enhances the tools you already use like Cursor or Windsurf, letting your agent work smarter without disrupting your workflow. 23 | 24 | For developers building larger scale software or non-developers bringing ideas to life, CodeLoops could transform your agent into a reliable autonomous partner. 25 | 26 | ## Quick Setup 27 | 28 | Get CodeLoops up and running in minutes: 29 | 30 | ```bash 31 | # Clone the repository 32 | git clone https://github.com/matsilva/codeloops.git 33 | cd codeloops 34 | 35 | # Run the setup script 36 | npm run setup 37 | ``` 38 | 39 | The script automates: 40 | 41 | - Verifying prerequisites (Node.js, Python, uv). 42 | - Installing dependencies. 43 | - Configuring Python environments. 44 | - Prompting for API key setup for models like Anthropic or OpenAI. 45 | 46 | > **Tip**: I’ve had great results with Anthropic’s Haiku 3.5, costing about $0.60 weekly. It’s a solid starting point. 47 | 48 | If this script fails, see [install guide](./docs/INSTALL_GUIDE.md) for installing the project dependencies 49 | 50 | ### Configure Your Agent 51 | 52 | Connect your agent to the CodeLoops server by adding the MCP server configuration. CodeLoops supports both stdio and HTTP transports: 53 | 54 | #### Option 1: Stdio Transport (Default) 55 | ```json 56 | "mcp": { 57 | "servers": { 58 | "codeloops": { 59 | "type": "stdio", 60 | "command": "npx", 61 | "args": ["-y", "tsx", "/path/to/codeloops/src"] 62 | } 63 | } 64 | } 65 | ``` 66 | 67 | #### Option 2: HTTP Transport 68 | ```json 69 | "mcp": { 70 | "servers": { 71 | "codeloops": { 72 | "type": "http", 73 | "url": "http://localhost:3000" 74 | } 75 | } 76 | } 77 | ``` 78 | 79 | 80 | For HTTP transport, start the server first: 81 | ```bash 82 | npm run start:http 83 | # or with custom port/host 84 | npx -y tsx src --http --port 8080 --host 127.0.0.1 85 | ``` 86 | 87 | Refer to your platform's documentation for specific MCP configuration instructions. 88 | 89 | ## CLI Options 90 | 91 | CodeLoops supports the following command-line options: 92 | 93 | - `--stdio`: Use stdio transport (default) 94 | - `--http`: Use HTTP transport 95 | - `--port `: HTTP server port (default: 3000) 96 | - `--host `: HTTP server host (default: 0.0.0.0) 97 | - `--help`: Show help message 98 | 99 | **Examples:** 100 | ```bash 101 | # Start with stdio (default) 102 | npm start 103 | 104 | # Start HTTP server on default port 3000 105 | npm run start:http 106 | 107 | # Start HTTP server on custom port 108 | npx -y tsx src --http --port 8080 109 | 110 | # Start HTTP server on specific host and port 111 | npx -y tsx src --http --host 127.0.0.1 --port 9000 112 | ``` 113 | 114 | ## Using CodeLoops 115 | 116 | With the server connected, instruct your agent to use CodeLoops for autonomous planning and coding. 117 | 118 | ### Example Prompt 119 | 120 | ``` 121 | Use codeloops to plan and implement the following: 122 | ... (insert your product requirements here) 123 | ``` 124 | 125 | ## Available Tools 126 | 127 | CodeLoops provides tools to enable autonomous agent operation: 128 | 129 | - `actor_think`: Drives interaction with the actor-critic system, automatically triggering critic reviews when needed. 130 | - `resume`: Retrieves recent branch context for continuity. 131 | - `export`: Exports the current graph for agent review. 132 | - `summarize`: Generates a summary of branch progress. 133 | - `list_projects`: Displays all projects for navigation. 134 | 135 | ## Basic Workflow 136 | 137 | 1. **Plan**: Add planning nodes with `actor_think`, guided by the critic. 138 | 2. **Implement**: Use `actor_think` for coding steps, refined in real time. 139 | 3. **Review**: The critic autonomously evaluates and corrects. 140 | 4. **Summarize**: Use `summarize` to generate clear summaries. 141 | 5. **Provide Feedback**: Offer human-in-the-loop input as needed to refine outcomes. YMMV depenting on how smart the coding agent is. 142 | 143 | CodeLoops leverages an actor-critic model with a knowledge graph, where the Critic can delegate to a chain of specialized agents for enhanced precision: 144 | 145 | ``` 146 | ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ 147 | │ AI Agent │────▶│ Actor │────▶│ Knowledge │ 148 | │ │◀────│ │◀────│ Graph │ 149 | └─────────────┘ └─────────────┘ └─────────────┘ 150 | │ ▲ 151 | ▼ │ 152 | ┌─────────────┐ │ 153 | │ Critic │────────────┼───┐ 154 | │ │ │ │ 155 | └─────────────┘ │ │ 156 | │ │ │ 157 | ▼ │ ▼ 158 | ┌─────────────┐ ┌─────────────┐ 159 | │ Specialized │ │ Summarizer │ 160 | │ Agents │ │ │ 161 | │ (Duplicate │ │ │ 162 | │ Code, │ │ │ 163 | │ Interface, │ │ │ 164 | │ Best │ │ │ 165 | │ Practices, │ │ │ 166 | │ etc.) │ │ │ 167 | └─────────────┘ └─────────────┘ 168 | ``` 169 | 170 | This architecture enables your agent to maintain context, refine decisions through specialized checks, and operate autonomously with greater reliability. 171 | 172 | ### Need Help? 173 | 174 | - Check [GitHub issues](https://github.com/silvabyte/codeloops/issues). 175 | - File a new issue with details. 176 | - **Email Me**: [mat@silvabyte.com](mailto:mat@silvabyte.com). 177 | - **X**: [Reach out on X](https://x.com/MatSilva). 178 | 179 | ### License & contributing 180 | 181 | This project is entirely experimental. Use at your own risk. & do what you want with it. 182 | 183 | MIT see [license](../LICENSE) 184 | -------------------------------------------------------------------------------- /agents/critic/.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /agents/critic/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silvabyte/codeloops/ab6ffb063bee8ae1d18248c29e0e58ab6522f189/agents/critic/README.md -------------------------------------------------------------------------------- /agents/critic/agent.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from mcp_agent.core.fastagent import FastAgent 3 | 4 | fast = FastAgent("CodeLoops Quality Critic") 5 | 6 | 7 | @fast.agent( 8 | instruction="""You are the Quality Critic in the CodeLoops system, responsible for evaluating and improving the quality of code generation. 9 | 10 | ## System Architecture 11 | You are part of the CodeLoops system with these key components: 12 | - Actor: Generates new thought nodes and code 13 | - Critic (you): Evaluates actor nodes and provides feedback 14 | - ActorCriticEngine: Coordinates the actor-critic loop 15 | - KnowledgeGraphManager: Stores all nodes, artifacts, and relationships 16 | 17 | ## DagNode Schema 18 | You review nodes with this structure: 19 | ```typescript 20 | interface DagNode { 21 | id: string; 22 | thought: string; 23 | role: 'actor' | 'critic'; 24 | verdict?: 'approved' | 'needs_revision' | 'reject'; 25 | verdictReason?: string; 26 | target?: string; // nodeId this criticises 27 | parents: string[]; 28 | children: string[]; 29 | createdAt: string; // ISO timestamp 30 | projectContext: string;// full path to the currently open directory in the code editor 31 | tags?: string[]; // categories ("design", "task", etc.) 32 | artifacts?: ArtifactRef[]; // attached artifacts 33 | } 34 | ``` 35 | 36 | ## Actor Schema Requirements 37 | The actor must follow these schema requirements: 38 | 1. `thought`: Must be non-empty and describe the work done 39 | 2. `tags`: Must include at least one semantic tag (requirement, task, risk, design, definition) 40 | 3. `artifacts`: Must be included when files are referenced in the thought 41 | 4. `projectContext`: Must be included to infer the project name from the last item in the path. 42 | 43 | ## Your Review Process 44 | When reviewing an actor node: 45 | 1. Set the appropriate verdict: 'approved', 'needs_revision', or 'reject' 46 | 2. Provide a clear verdictReason when requesting revisions 47 | 3. respond with a single line response with the json format: {"verdict": "approved|needs_revision|reject", "verdictReason": "reason for revision if needed"} 48 | 49 | ## Specific Checks to Perform 50 | - File References: Detect file paths/names in thought to ensure relevant artifacts are attached 51 | - Tag Validation: Ensure semantic tag is relevant and meaningful for future searches 52 | - Duplicate Detection: Look for similar components/APIs in the knowledge graph 53 | - Code Quality: Flag issues like @ts-expect-error, TODOs, or poor practices 54 | 55 | ## Verdict Types 56 | - `approved`: The node meets all requirements and can proceed 57 | - `needs_revision`: The node needs specific improvements (always include verdictReason) 58 | - `reject`: The node is fundamentally flawed or has reached max revision attempts (default: 2) 59 | """ 60 | ) 61 | async def main(): 62 | # use the --model command line switch or agent arguments to change model 63 | async with fast.run() as agent: 64 | await agent.interactive() 65 | 66 | 67 | if __name__ == "__main__": 68 | asyncio.run(main()) 69 | -------------------------------------------------------------------------------- /agents/critic/critic.egg-info/PKG-INFO: -------------------------------------------------------------------------------- 1 | Metadata-Version: 2.4 2 | Name: critic 3 | Version: 0.1.0 4 | Summary: Add your description here 5 | Requires-Python: >=3.11 6 | Description-Content-Type: text/markdown 7 | Requires-Dist: fast-agent-mcp>=0.2.20 8 | -------------------------------------------------------------------------------- /agents/critic/critic.egg-info/SOURCES.txt: -------------------------------------------------------------------------------- 1 | README.md 2 | agent.py 3 | pyproject.toml 4 | critic.egg-info/PKG-INFO 5 | critic.egg-info/SOURCES.txt 6 | critic.egg-info/dependency_links.txt 7 | critic.egg-info/requires.txt 8 | critic.egg-info/top_level.txt -------------------------------------------------------------------------------- /agents/critic/critic.egg-info/dependency_links.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /agents/critic/critic.egg-info/requires.txt: -------------------------------------------------------------------------------- 1 | fast-agent-mcp>=0.2.20 2 | -------------------------------------------------------------------------------- /agents/critic/critic.egg-info/top_level.txt: -------------------------------------------------------------------------------- 1 | agent 2 | -------------------------------------------------------------------------------- /agents/critic/fastagent.config.template.yaml: -------------------------------------------------------------------------------- 1 | # RENAME THIS FILE TO fastagent.config.yaml 2 | 3 | # FastAgent Configuration File 4 | 5 | # Default Model Configuration: 6 | # 7 | # Takes format: 8 | # .. (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini.low) 9 | # Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3 10 | # and OpenAI Models: gpt-4.1, gpt-4.1-mini, o1, o1-mini, o3-mini 11 | # 12 | # If not specified, defaults to "haiku". 13 | # Can be overriden with a command line switch --model=, or within the Agent constructor. 14 | 15 | default_model: haiku 16 | 17 | # Logging and Console Configuration: 18 | logger: 19 | # level: "debug" | "info" | "warning" | "error" 20 | # type: "none" | "console" | "file" | "http" 21 | # path: "/path/to/logfile.jsonl" 22 | 23 | # Switch the progress display on or off 24 | progress_display: false 25 | 26 | # Show chat User/Assistant messages on the console 27 | show_chat: false 28 | # Show tool calls on the console 29 | show_tools: false 30 | # Truncate long tool responses on the console 31 | truncate_tools: false 32 | # MCP Servers 33 | # You can modify this as needed 34 | # mcp: 35 | # servers: 36 | # fetch: 37 | # command: 'uvx' 38 | # args: ['mcp-server-fetch'] 39 | # filesystem: 40 | # command: 'npx' 41 | # args: ['-y', '@modelcontextprotocol/server-filesystem', '.'] 42 | -------------------------------------------------------------------------------- /agents/critic/fastagent.secrets.template.yaml: -------------------------------------------------------------------------------- 1 | # RENAME THIS FILE TO fastagent.secrets.yaml 2 | 3 | # FastAgent Secrets Configuration 4 | # WARNING: Keep this file secure and never commit to version control 5 | 6 | # Alternatively set OPENAI_API_KEY, ANTHROPIC_API_KEY or other environment variables. 7 | # Keys in the configuration file override environment variables. 8 | 9 | # openai: 10 | # api_key: 11 | 12 | # I am using anthropic for my testing, feel free to use a provider of your choice 13 | anthropic: 14 | api_key: 15 | # deepseek: 16 | # api_key: 17 | # openrouter: 18 | # api_key: 19 | 20 | # # Example of setting an MCP Server environment variable 21 | # mcp: 22 | # servers: 23 | # brave: 24 | # env: 25 | # BRAVE_API_KEY: 26 | -------------------------------------------------------------------------------- /agents/critic/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "critic" 3 | version = "0.1.0" 4 | description = "A CodeLoops critic agent" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | dependencies = [ 8 | "fast-agent-mcp>=0.2.20" 9 | ] 10 | -------------------------------------------------------------------------------- /agents/summarize/.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /agents/summarize/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silvabyte/codeloops/ab6ffb063bee8ae1d18248c29e0e58ab6522f189/agents/summarize/README.md -------------------------------------------------------------------------------- /agents/summarize/agent.py: -------------------------------------------------------------------------------- 1 | import asyncio, json, re, sys 2 | from mcp_agent.core.fastagent import FastAgent 3 | 4 | # --------------------------------------------------------------------------- # 5 | # Agent instruction # 6 | # --------------------------------------------------------------------------- # 7 | 8 | INSTRUCTION = r""" 9 | You are the Summarization Agent in the CodeLoops system, responsible for creating 10 | concise summaries of knowledge-graph segments. 11 | 12 | ## System Architecture 13 | You are part of the CodeLoops system with these key components: 14 | - KnowledgeGraphManager: Stores all nodes, artifacts, and relationships 15 | - Actor: Generates new thought nodes and code 16 | - Critic: Evaluates actor nodes and provides feedback 17 | - Summarization Agent (you): Creates concise summaries of knowledge graph segments 18 | - ActorCriticEngine: Coordinates the actor-critic loop 19 | 20 | ## DagNode Schema 21 | ```typescript 22 | interface DagNode { 23 | id: string; 24 | thought: string; 25 | role: 'actor' | 'critic'; 26 | verdict?: 'approved' | 'needs_revision' | 'reject'; 27 | verdictReason?: string; 28 | verdictReferences?: string[]; 29 | target?: string; // nodeId this criticises 30 | parents: string[]; 31 | children: string[]; 32 | createdAt: string; // ISO timestamp 33 | projectContext: string; // full path to the open directory 34 | tags?: string[]; // categories ("design", "task", etc.) 35 | artifacts?: ArtifactRef[]; 36 | } 37 | ``` 38 | 39 | ## Your Summarisation Process 40 | 1. Analyse the sequence of thoughts, focusing on key decisions, artifacts, and concepts 41 | 2. Identify the main themes and progression of work 42 | 3. Produce a concise summary (1–3 paragraphs) 43 | 4. Include references to important artifacts and definitions 44 | 5. Reply **only** with JSON: {"summary":"...", "error":""} 45 | 46 | Guidelines: focus on high-level ideas, highlight artifacts, keep it brief yet clear. 47 | """ 48 | 49 | fast = FastAgent("CodeLoops Summarization Agent") 50 | 51 | 52 | # --------------------------------------------------------------------------- # 53 | # Parsing and normalisation helpers # 54 | # --------------------------------------------------------------------------- # 55 | 56 | 57 | def _first_json_object(text: str) -> dict: 58 | """Return the first balanced JSON object embedded in *text*.""" 59 | start = text.find("{") 60 | if start == -1: 61 | raise json.JSONDecodeError("no opening brace", text, 0) 62 | 63 | depth, in_str, esc = 0, False, False 64 | for i, ch in enumerate(text[start:], start): 65 | if in_str: 66 | esc = not esc and ch == "\\" or (esc and False) 67 | in_str = not (ch == '"' and not esc) or in_str 68 | continue 69 | if ch == '"': 70 | in_str = True 71 | elif ch == "{": 72 | depth += 1 73 | elif ch == "}": 74 | depth -= 1 75 | if depth == 0: 76 | return json.loads(text[start : i + 1]) 77 | 78 | raise json.JSONDecodeError("no balanced object", text, start) 79 | 80 | 81 | def parse_and_normalize_reply(reply: str) -> dict: 82 | """Convert an LLM reply into {"summary": str, "error": str}.""" 83 | last_err = "unknown parsing failure" 84 | 85 | # scenario: normal json response 86 | try: 87 | if reply.lstrip().startswith("{"): 88 | data = json.loads(reply) 89 | return {"summary": data.get("summary", ""), "error": data.get("error", "")} 90 | except json.JSONDecodeError as e: 91 | last_err = f"direct load failed → {e}" 92 | 93 | # scenario: fenced block 94 | unfenced = re.sub(r"^\s*```(?:json)?\s*|\s*```$", "", reply, flags=re.DOTALL) 95 | if unfenced != reply: 96 | try: 97 | data = json.loads(unfenced) 98 | return {"summary": data.get("summary", ""), "error": data.get("error", "")} 99 | except json.JSONDecodeError as e: 100 | last_err = f"fenced load failed → {e}" 101 | 102 | # scenario: get first json object if llm is too chatty 103 | try: 104 | data = _first_json_object(reply) 105 | return {"summary": data.get("summary", ""), "error": data.get("error", "")} 106 | except (json.JSONDecodeError, ValueError) as e: 107 | last_err = f"embedded object failed → {e}" 108 | 109 | # scenario: fallback to plain text in case of exausted failure scenarios 110 | if reply.strip(): 111 | return {"summary": reply.strip(), "error": ""} 112 | 113 | return {"summary": "", "error": last_err} 114 | 115 | 116 | # --------------------------------------------------------------------------- # 117 | # Agent runtime # 118 | # --------------------------------------------------------------------------- # 119 | 120 | 121 | @fast.agent(instruction=INSTRUCTION) 122 | async def main(): 123 | async with fast.run() as agent: 124 | 125 | async def summarise(nodes: list[dict]) -> dict: 126 | prompt = ( 127 | "Please summarise the following knowledge-graph segment:\n\n" 128 | + json.dumps(nodes, indent=2) 129 | ) 130 | reply = await agent.send(prompt) 131 | 132 | for _ in range(2): # allow one correction round 133 | result = parse_and_normalize_reply(reply) 134 | if result["error"]: 135 | reply = await agent.send( 136 | 'Respond ONLY with valid JSON: {"summary":"...", "error":""}' 137 | ) 138 | else: 139 | return result 140 | 141 | return {"summary": "", "error": "agent returned unparsable output twice"} 142 | 143 | # retain CLI flag for programmatic callers --------------------------------- 144 | if "--summarize" in sys.argv: 145 | try: 146 | data = json.load(sys.stdin) 147 | if not isinstance(data, list) or not data: 148 | raise ValueError("Input must be a non-empty JSON array of nodes") 149 | print(json.dumps(await summarise(data))) 150 | except Exception as err: 151 | print(json.dumps({"summary": "", "error": str(err)})) 152 | else: 153 | # choose mode automatically when run via fast-agent CLI helpers 154 | if sys.stdin.isatty(): 155 | await agent.interactive() 156 | else: 157 | try: 158 | data = json.load(sys.stdin) 159 | print(json.dumps(await summarise(data))) 160 | except Exception as err: 161 | print(json.dumps({"summary": "", "error": str(err)})) 162 | 163 | 164 | if __name__ == "__main__": 165 | asyncio.run(main()) 166 | -------------------------------------------------------------------------------- /agents/summarize/fastagent.config.template.yaml: -------------------------------------------------------------------------------- 1 | # RENAME THIS FILE TO fastagent.config.yaml 2 | 3 | # FastAgent Configuration File 4 | 5 | # Default Model Configuration: 6 | # 7 | # Takes format: 8 | # .. (e.g. anthropic.claude-3-5-sonnet-20241022 or openai.o3-mini.low) 9 | # Accepts aliases for Anthropic Models: haiku, haiku3, sonnet, sonnet35, opus, opus3 10 | # and OpenAI Models: gpt-4.1, gpt-4.1-mini, o1, o1-mini, o3-mini 11 | # 12 | # If not specified, defaults to "haiku". 13 | # Can be overriden with a command line switch --model=, or within the Agent constructor. 14 | 15 | default_model: haiku 16 | 17 | # Logging and Console Configuration: 18 | logger: 19 | # level: "debug" | "info" | "warning" | "error" 20 | # type: "none" | "console" | "file" | "http" 21 | # path: "/path/to/logfile.jsonl" 22 | 23 | # Switch the progress display on or off 24 | progress_display: false 25 | 26 | # Show chat User/Assistant messages on the console 27 | show_chat: false 28 | # Show tool calls on the console 29 | show_tools: false 30 | # Truncate long tool responses on the console 31 | truncate_tools: false 32 | # MCP Servers 33 | # You can modify this as needed 34 | # mcp: 35 | # servers: 36 | # fetch: 37 | # command: 'uvx' 38 | # args: ['mcp-server-fetch'] 39 | # filesystem: 40 | # command: 'npx' 41 | # args: ['-y', '@modelcontextprotocol/server-filesystem', '.'] 42 | -------------------------------------------------------------------------------- /agents/summarize/fastagent.secrets.template.yaml: -------------------------------------------------------------------------------- 1 | # RENAME THIS FILE TO fastagent.secrets.yaml 2 | 3 | # FastAgent Secrets Configuration 4 | # WARNING: Keep this file secure and never commit to version control 5 | 6 | # Alternatively set OPENAI_API_KEY, ANTHROPIC_API_KEY or other environment variables. 7 | # Keys in the configuration file override environment variables. 8 | 9 | # openai: 10 | # api_key: 11 | 12 | # I am using anthropic for my testing, feel free to use a provider of your choice 13 | anthropic: 14 | api_key: 15 | # deepseek: 16 | # api_key: 17 | # openrouter: 18 | # api_key: 19 | 20 | # # Example of setting an MCP Server environment variable 21 | # mcp: 22 | # servers: 23 | # brave: 24 | # env: 25 | # BRAVE_API_KEY: 26 | -------------------------------------------------------------------------------- /agents/summarize/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "summarize" 3 | version = "0.1.0" 4 | description = "CodeLoops branch summary agent" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | dependencies = [ 8 | "fast-agent-mcp>=0.2.20" 9 | ] 10 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /docs/INSTALL_GUIDE.md: -------------------------------------------------------------------------------- 1 | # CodeLoops: Installation Guide 2 | 3 | ## Prerequisites 4 | 5 | Before starting, ensure you have the following dependencies 6 | 7 | - **Node.js**: Version 18 or higher 8 | - Download from [nodejs.org](https://nodejs.org) or use a version manager like `nvm` 9 | - Verify with: `node --version` 10 | - **Python**: Version 3.11 or higher 11 | - Download from [python.org](https://www.python.org) 12 | - Verify with: `python3 --version` 13 | - **uv**: A modern Python package manager 14 | - Install per [uv documentation](https://docs.astral.sh/uv/getting-started/installation) 15 | - Verify with: `uv --version` 16 | - **API Keys**: Required for your chosen LLM provider (e.g., Anthropic, OpenAI) 17 | - Obtain keys from your provider’s dashboard 18 | 19 | ## Installation Steps 20 | 21 | ### Step 1: Clone the Repository 22 | 23 | 1. Open a terminal and clone the CodeLoops repository: 24 | ```bash 25 | git clone https://github.com/SilvaByte/codeloops.git 26 | ``` 27 | 2. Navigate to the project directory: 28 | ```bash 29 | cd codeloops 30 | ``` 31 | 3. Verify the repository structure: 32 | ```bash 33 | ls 34 | ``` 35 | You should see directories like `src`, `agents`, and files like `package.json`. 36 | 37 | ### Step 2: Understand the Project Structure 38 | 39 | The CodeLoops project has two main components: 40 | 41 | - **MCP Server** (Node.js): Manages the CodeLoops system and Knowledge Graph. 42 | - **Agent Components** (Python): Includes Critic and Summarization agents for evaluating and condensing information. 43 | 44 | Key directories: 45 | 46 | ``` 47 | codeloops/ 48 | ├── src/ # MCP server and core components 49 | │ ├── engine/ # Actor, Critic, and RevisionCounter 50 | │ ├── agents/ # Agent integration code 51 | │ └── ... # Other core components 52 | ├── agents/ # Python agent implementations 53 | │ ├── critic/ # Quality evaluation agent 54 | │ └── summarize/ # Branch summarization agent 55 | ├── package.json # Node.js dependencies 56 | └── README.md # Project documentation 57 | ``` 58 | 59 | ### Step 3: Install Node.js Dependencies 60 | 61 | 1. From the project root (`codeloops/`), install Node.js dependencies: 62 | ```bash 63 | npm install 64 | ``` 65 | 2. Verify installation: 66 | ```bash 67 | npm list 68 | ``` 69 | Ensure no errors appear, and dependencies like `typescript` and `tsx` are listed. 70 | 71 | ### Step 4: Set Up Python Agents 72 | 73 | The Critic and Summarization agents require separate Python environments managed by `uv`. 74 | 75 | #### 4.1 Critic Agent Setup 76 | 77 | 1. Navigate to the Critic agent directory: 78 | ```bash 79 | cd agents/critic 80 | ``` 81 | 2. Install Python dependencies using `uv`: 82 | ```bash 83 | uv sync 84 | ``` 85 | This creates a virtual environment and installs dependencies listed in `pyproject.toml`. 86 | 3. Copy configuration templates: 87 | ```bash 88 | cp fastagent.config.template.yaml fastagent.config.yaml 89 | cp fastagent.secrets.template.yaml fastagent.secrets.yaml 90 | ``` 91 | 4. Edit `fastagent.secrets.yaml` to include your LLM API key: 92 | ```yaml 93 | anthropic: 94 | api_key: your-anthropic-api-key 95 | # Example for OpenAI 96 | openai: 97 | api_key: your-openai-api-key 98 | ``` 99 | Replace `your-anthropic-api-key` or `your-openai-api-key` with your actual keys. 100 | 5. Verify configuration: 101 | ```bash 102 | uv run fast-agent check 103 | ``` 104 | This checks if the configuration and API keys are valid. 105 | 106 | For more info on LLM providers and models, see the [fast-agent docs](https://fast-agent.ai/models/llm_providers/) 107 | 108 | #### 4.2 Summarization Agent Setup 109 | 110 | 1. Navigate to the Summarization agent directory: 111 | ```bash 112 | cd ../summarize 113 | ``` 114 | 2. Install Python dependencies: 115 | ```bash 116 | uv sync 117 | ``` 118 | 3. Copy configuration templates: 119 | ```bash 120 | cp fastagent.config.template.yaml fastagent.config.yaml 121 | cp fastagent.secrets.template.yaml fastagent.secrets.yaml 122 | ``` 123 | 4. Edit `fastagent.secrets.yaml` with the same API keys used for the Critic agent. 124 | 5. Verify configuration: 125 | ```bash 126 | uv run fast-agent check 127 | ``` 128 | 129 | For more info on LLM providers and models, see the [fast-agent docs](https://fast-agent.ai/models/llm_providers/) 130 | 131 | #### 4.3 Understanding `uv sync` 132 | 133 | The `uv sync` command: 134 | 135 | - Reads `pyproject.toml` for dependency specifications 136 | - Creates or updates a virtual environment in `.venv` 137 | - Installs required packages 138 | - Generates a `uv.lock` file for reproducible builds 139 | 140 | If `uv sync` fails, ensure `uv` is installed and Python 3.11+ is available. 141 | 142 | ### Step 5: Test the MCP Server 143 | 144 | CodeLoops supports both stdio and HTTP transports. Test both to ensure proper functionality: 145 | 146 | #### Option 1: Test Stdio Transport (Default) 147 | 1. Start the MCP server: 148 | ```bash 149 | npx -y tsx src 150 | ``` 151 | 2. The server should start without any errors and wait for input via stdio 152 | 153 | #### Option 2: Test HTTP Transport 154 | 1. Start the HTTP server: 155 | ```bash 156 | npm run start:http 157 | # or with custom options 158 | npx -y tsx src --http --port 3000 159 | ``` 160 | 2. The server should start and display: 161 | ``` 162 | CodeLoops HTTP server running on http://0.0.0.0:3000 163 | ``` 164 | 3. Test the server health endpoint: 165 | ```bash 166 | curl http://localhost:3000/health 167 | ``` 168 | You should receive a JSON response indicating the server is running. 169 | 170 | To stop the HTTP server, use `Ctrl+C`. 171 | 172 | ### Step 6: Test the Agent Config 173 | 174 | - From the `agents/critic` directory, verify the Critic agent’s configuration and connectivity: 175 | ```bash 176 | fast-agent check 177 | ``` 178 | This ensures the agent’s configuration, API keys, etc are valid 179 | - Navigate to the Summarization agent directory and repeat: 180 | ```bash 181 | cd ../summarize 182 | fast-agent check 183 | ``` 184 | Expect confirmation that both agents are correctly set up and can communicate with the MCP server. 185 | -------------------------------------------------------------------------------- /docs/OVERVIEW.md: -------------------------------------------------------------------------------- 1 | ![CodeLoops](../codeloops_banner.svg) 2 | 3 | # CodeLoops - Detailed Overview 4 | 5 | A feedback-driven system to improve coding agents through actor-critic loops, consolidating knowledge graph memory & sequential thinking into more useful tooling. 6 | 7 | ## Why this exists 8 | 9 | - [Read announcement](https://bytes.silvabyte.com/improving-coding-agents-an-early-look-at-codeloops-for-building-more-reliable-software/) to learn more. 10 | 11 | Modern coding agents forget what they wrote a few minutes ago **and** can't trace which early design choice broke the build four moves later. 12 | 13 | That's two separate failures: 14 | 15 | | Layer | Failure | Symptom set | 16 | | ---------------------- | -------------------------------------------- | ------------------------------------------------------------------------- | 17 | | **Memory / Retrieval** | Context falls out of scope | forgotten APIs • duplicated components • dead code • branch drift | 18 | | **Credit Assignment** | Model can't link early moves to late rewards | oscillating designs • premature optimisations • mis‑prioritised refactors | 19 | 20 | LLMs _learn_ with actor–critic loops that solve temporal difference and credit assignment problems, but tool builders drop that loop at inference time. 21 | 22 | Actor–Critic MCP attempts to bring it back **and** closes the memory hole. 23 | 24 | Keep in mind: Memory issues ≠ Temporal‑Difference issues 25 | 26 | --- 27 | 28 | ### AI Agent Context Gaps 29 | 30 | Here is a short catalogue of problems I have encountered pair-programming with AI agents. 31 | 32 | #### **Memory loss & retrieval** 33 | 34 | 1. **Forgetting prior definitions** – APIs, schemas, or components fall outside the window and get reinvented. 35 | 2. **Rules / guidelines ignored** – Rule files (`.rules.md`, ESLint, naming docs) are rarely pulled into context or linked to reward, so conventions drift. 36 | 3. **Unstructured or missing summaries** – Older work isn't compacted, so long‑range reasoning decays. 37 | 4. **No proactive retrieval** – Tools like `resume` or `search_plan` aren't invoked, leaving blind spots. 38 | 5. **Forgetting exploration outcomes** – Rejected ideas resurface; time is wasted on déjà‑vu fixes. 39 | 6. **Buried open questions** – "TBD" items never resurface, so design gaps remain unresolved. 40 | 41 | #### **Consistency & integrity drift** 42 | 43 | 7. **Component duplication / naming drift** – Same concept, new name; specs splinter. 44 | 8. **Weak code linkage** – Thoughts aren't tied to artifacts; the agent doubts or overwrites its own work. 45 | 9. **Dead code & divergence from plan** – Unused files linger; implementation strays from intent. 46 | 10. **Poor hygiene routines** – No systematic search/reuse/tag cycle → metadata rot. 47 | 11. **Loss of intent hierarchy** – Downstream tasks optimise locally and break upstream goals. 48 | 12. **Stale assumptions** – New requirements don't invalidate old premises; bad foundations spread. 49 | 13. **Branch divergence without reconciliation** – Parallel fixes never merge; logic conflicts. 50 | 14. **No cross‑branch dependency tracking** – Change auth model here, tests fail over there. 51 | 52 | Have more problems to add? File an issue to suggest adding to the list. 53 | 54 | ## What this project is 55 | 56 | ```mermaid 57 | %% CodeLoops high‑level flow 58 | graph TD 59 | A[Caller Agent Copilot, Cursor, ...] --> B[MCP Server] 60 | B --> C[Knowledge-Graph Memory] 61 | B --> D[Actor] 62 | D <--> E[Critic] 63 | D --> C 64 | E --> C 65 | 66 | subgraph "Server Transports" 67 | F[Stdio Transport] 68 | G[HTTP Transport] 69 | end 70 | 71 | A --> F 72 | A --> G 73 | F --> B 74 | G --> B 75 | ``` 76 | 77 | High‑level flow: caller → MCP Server (stdio/HTTP) → KG + Actor/Critic loop 78 | 79 | - **Coding Agent** 80 | Calls the CodeLoops system via MCP protocol 81 | - **MCP Server** 82 | Supports both stdio and HTTP transports for flexible integration 83 | - **Knowledge Graph** 84 | Compact summaries + full artefacts; fast semantic lookup; survives crashes. 85 | - **Actor** 86 | Generates the next code / plan node. Writes links into the graph. 87 | - **Critic** 88 | Scores each node against long‑horizon goals; updates value estimates; can veto or request revision. 89 | - **Hot‑Context Stream** 90 | Only the freshest, highest‑value nodes return to the LLM to keep within token budgets. 91 | 92 | ### Actor-Critic Workflow 93 | 94 | The actor-critic system follows this workflow: 95 | 96 | 1. The agent calls `actor_think` to add a new thought node to the knowledge graph 97 | 2. The critic evaluates the node and provides a verdict: 98 | - `approved`: The node meets all requirements 99 | - `needs_revision`: The node needs specific improvements 100 | - `reject`: The node is fundamentally flawed or has reached max revision attempts 101 | 102 | In most cases, you don't need to call `critic_review` directly as it's automatically triggered by `actor_think` when appropriate. The `critic_review` tool is primarily useful for manual intervention, forcing a review of a specific previous node, or debugging purposes. 103 | 104 | ### Project Management Tools 105 | 106 | The actor-critic system supports working with multiple knowledge graph projects: 107 | 108 | 1. **`list_projects`**: Lists all available knowledge graph projects 109 | 110 | ## Background 111 | 112 | While the context gap is not directly a temporal difference problem, it lends itself to the concepts of temporal difference and credit assignment. 113 | So it is helpful to understand these concepts in order to solve the context gaps. 114 | 115 | **TLDR;** 116 | 117 | TD = delayed reward. 118 | Credit assignment = which earlier step deserves the reward. 119 | Actor–Critic solves both: Actor acts, Critic scores, value propagates back. 120 | 121 | ### What is a temporal difference problem? 122 | 123 | An example: when an AI wins or loses a game of chess, it might not remember the specific moves it made earlier in the game that led to that outcome. This is a temporal difference problem, the result comes much later than the decisions that influenced it. 124 | 125 | This ties directly into another concept: the credit assignment problem. 126 | 127 | --- 128 | 129 | **What is the credit assignment problem?** 130 | 131 | To build on the example above: how does the AI figure out which specific moves actually contributed to the win or loss? Not all moves mattered equally. The AI needs to assign credit (or blame) across a timeline of actions. 132 | 133 | --- 134 | 135 | **How do the two connect?** 136 | 137 | The temporal difference problem is about **delayed consequences**. Decisions made now might not show their results until much later. 138 | The credit assignment problem is about **figuring out which of those decisions mattered most** when the result finally does come. 139 | 140 | Together, they form one of the most challenging problems in long-horizon reasoning. 141 | 142 | --- 143 | 144 | **How was this solved?** 145 | 146 | This was a sticky problem for a long time, but one of the more effective approaches turned out to be the actor–critic model. 147 | 148 | Here's how it works: 149 | 150 | - The **actor** is responsible for making decisions (moves, in the chess example). 151 | - The **critic** provides feedback. It evaluates whether those decisions seem likely to lead to a good outcome. 152 | - If the critic believes the actor's move is good, the actor continues. If not, the actor tries a better move. 153 | 154 | Over time, the actor learns which moves tend to lead to good results, even if the payoff comes much later. This model helps the AI assign value to intermediate steps, instead of only learning from the final outcome. 155 | 156 | So for our purposes, the actor is the coding agent, and the critic is made available via an MCP. I am hoping to figure out how we might tie rewards back to agent moves(ie; code gen). 157 | 158 | --- 159 | 160 | ### License & contributing 161 | 162 | This project is entirely experimental. Use at your own risk. & do what you want with it. 163 | 164 | MIT see [license](../LICENSE) 165 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import tseslint from "typescript-eslint"; 4 | import { defineConfig } from "eslint/config"; 5 | import * as importPlugin from "eslint-plugin-import"; 6 | 7 | 8 | export default defineConfig([ 9 | { files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js }, extends: ["js/recommended"] }, 10 | { files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], languageOptions: { globals: globals.browser } }, 11 | tseslint.configs.recommended, 12 | { 13 | plugins: { import: importPlugin } 14 | }, 15 | { 16 | //do not lint python files 17 | ignores: ['agents/*', 'node_modules/*'] 18 | } 19 | ]); 20 | -------------------------------------------------------------------------------- /logs/logs.md: -------------------------------------------------------------------------------- 1 | This directory is used to store logs for debugging and analysis. 2 | -------------------------------------------------------------------------------- /media/png/code_loops_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silvabyte/codeloops/ab6ffb063bee8ae1d18248c29e0e58ab6522f189/media/png/code_loops_icon.png -------------------------------------------------------------------------------- /media/png/code_loops_icon_transparent_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silvabyte/codeloops/ab6ffb063bee8ae1d18248c29e0e58ab6522f189/media/png/code_loops_icon_transparent_dark.png -------------------------------------------------------------------------------- /media/png/code_loops_logo_transparent_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silvabyte/codeloops/ab6ffb063bee8ae1d18248c29e0e58ab6522f189/media/png/code_loops_logo_transparent_dark.png -------------------------------------------------------------------------------- /media/png/codeloops_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silvabyte/codeloops/ab6ffb063bee8ae1d18248c29e0e58ab6522f189/media/png/codeloops_banner.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codeloops", 3 | "version": "0.5.1", 4 | "main": "index.js", 5 | "type": "module", 6 | "scripts": { 7 | "test": "vitest run", 8 | "format": "prettier --write .", 9 | "prepare": "husky", 10 | "lint": "eslint .", 11 | "lint:all": "npm run lint && npm run type-check", 12 | "setup": "bash scripts/setup.sh", 13 | "start": "npx -y tsx src", 14 | "start:http": "npx -y tsx src --http", 15 | "type-check": "tsc --noEmit", 16 | "test-quickstart": "bash scripts/test-quickstart.sh" 17 | }, 18 | "keywords": [ 19 | "codeloops", 20 | "actor-critic", 21 | "ai", 22 | "coding-agent", 23 | "feedback-loop" 24 | ], 25 | "author": "Mat Silva ", 26 | "license": "ISC", 27 | "description": "CodeLoops: A feedback-driven system to improve coding agents through actor-critic loops", 28 | "devDependencies": { 29 | "@commitlint/cli": "^19.8.0", 30 | "@commitlint/config-conventional": "^19.8.0", 31 | "@eslint/js": "^9.27.0", 32 | "@types/node": "^22.15.3", 33 | "@types/proper-lockfile": "^4.1.4", 34 | "eslint": "^9.27.0", 35 | "globals": "^16.1.0", 36 | "husky": "^9.1.7", 37 | "typescript-eslint": "^8.32.1", 38 | "vitest": "^3.1.2" 39 | }, 40 | "dependencies": { 41 | "@modelcontextprotocol/sdk": "^1.11.0", 42 | "fastify": "^5.2.0", 43 | "@tsconfig/node22": "^22.0.1", 44 | "await-to-js": "^3.0.0", 45 | "chalk": "^5.4.1", 46 | "console-table": "^0.1.2", 47 | "eslint-config-prettier": "^10.1.5", 48 | "eslint-plugin-import": "^2.31.0", 49 | "execa": "^9.5.2", 50 | "nanoid": "^5.1.5", 51 | "pino": "^9.6.0", 52 | "pino-pretty": "^13.0.0", 53 | "pino-roll": "^3.1.0", 54 | "prettier": "^3.5.3", 55 | "proper-lockfile": "^4.1.2", 56 | "table": "^6.9.0", 57 | "typescript": "^5.8.3", 58 | "uuid": "^11.1.0", 59 | "zod": "^3.24.3" 60 | } 61 | } -------------------------------------------------------------------------------- /scripts/migrations/migrate021_030.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Migration script to convert existing per-project JSON files to the new unified NDJSON format 3 | * 4 | * This script: 5 | * 1. Reads all existing kg.*.json files in the data directory 6 | * 2. Extracts the project name from each file name 7 | * 3. Parses the JSON content and adds the project field to each entity 8 | * 4. Writes all entities to the new knowledge_graph.ndjson file 9 | * 5. Includes error handling and logging 10 | * 11 | * Usage: ts-node scripts/migrate.ts 12 | */ 13 | 14 | import fs from 'node:fs/promises'; 15 | import * as fsSync from 'node:fs'; 16 | import path from 'node:path'; 17 | import { dataDir } from '../../src/config.ts'; 18 | import { createLogger, getInstance as getLogger } from '../../src/logger.ts'; 19 | import { extractProjectName } from '../../src/utils/project.ts'; 20 | import { createInterface } from 'node:readline'; 21 | import { createCodeLoopsAscii } from '../../src/utils/fun.ts'; 22 | import { DagNode } from '../../src/engine/KnowledgeGraph.ts'; 23 | import { table } from 'table'; 24 | import chalk from 'chalk'; 25 | 26 | const logger = getLogger({ withDevStdout: true, sync: true }); 27 | const silentLogger = createLogger({ sync: true }); 28 | 29 | // Path to the new NDJSON file 30 | const ndjsonFilePath = path.resolve(dataDir, 'knowledge_graph.ndjson'); 31 | const failedEntitiesFilePath = path.resolve(dataDir, 'failed_entities.ndjson'); 32 | const backupDir = path.resolve(dataDir, 'backups'); 33 | const backupPathFile = path.resolve(backupDir, `${ndjsonFilePath}.backup.${Date.now()}`); 34 | if (!fsSync.existsSync(backupDir)) { 35 | fsSync.mkdirSync(backupDir, { recursive: true }); 36 | } 37 | 38 | // Regular expression to extract project name from file name 39 | const projectFileRegex = /^kg\.(.+)\.json$/; 40 | 41 | async function migrateProjectFiles() { 42 | console.log(createCodeLoopsAscii()); 43 | logger.info('Starting migration of knowledge graph to unified knowledge graph NDJSON format'); 44 | try { 45 | // Create a backup of the NDJSON file if it already exists 46 | if (fsSync.existsSync(ndjsonFilePath)) { 47 | silentLogger.info(`Creating backup of ${ndjsonFilePath} to ${backupPathFile}`); 48 | await fs.copyFile(ndjsonFilePath, backupPathFile); 49 | } 50 | 51 | // Get all files in the data directory 52 | silentLogger.info(`Reading files in ${dataDir}`); 53 | const files = await fs.readdir(dataDir); 54 | 55 | // Filter for project files 56 | const projectFiles = files.filter( 57 | (file) => projectFileRegex.test(file) && file !== 'kg.json', // Exclude the legacy kg.json file 58 | ); 59 | 60 | logger.info(`Projects found: ${projectFiles.length}`); 61 | 62 | // Create or clear the NDJSON file 63 | silentLogger.info(`Creating NDJSON file at ${ndjsonFilePath}`); 64 | await fs.writeFile(ndjsonFilePath, '', 'utf8'); 65 | 66 | const migrationMetrics = { 67 | totalProcessed: 0, 68 | totalFailed: 0, 69 | projects: {}, 70 | }; 71 | 72 | const incrementTotalProcessed = () => { 73 | migrationMetrics.totalProcessed++; 74 | }; 75 | 76 | const incrementTotalFailed = () => { 77 | migrationMetrics.totalFailed++; 78 | }; 79 | 80 | const incrementProjectProcessed = (projectName: string) => { 81 | incrementTotalProcessed(); 82 | if (!migrationMetrics.projects[projectName]) { 83 | migrationMetrics.projects[projectName] = { processed: 0, failed: 0 }; 84 | } 85 | const projectMetrics = migrationMetrics.projects[projectName]; 86 | if (!projectMetrics) return; 87 | projectMetrics.processed++; 88 | migrationMetrics.projects[projectName] = projectMetrics; 89 | }; 90 | 91 | const incrementProjectFailed = (projectName: string) => { 92 | incrementTotalFailed(); 93 | if (!migrationMetrics.projects[projectName]) { 94 | migrationMetrics.projects[projectName] = { processed: 0, failed: 0 }; 95 | } 96 | const projectMetrics = migrationMetrics.projects[projectName]; 97 | if (!projectMetrics) return; 98 | projectMetrics.failed++; 99 | migrationMetrics.projects[projectName] = projectMetrics; 100 | }; 101 | 102 | for (const file of projectFiles) { 103 | const match = file.match(projectFileRegex); 104 | if (!match) { 105 | silentLogger.info(`Skipping file ${file}`); 106 | continue; 107 | } 108 | const projectName = match[1]; 109 | const filePath = path.resolve(dataDir, file); 110 | 111 | logger.info('Processing project: ' + projectName); 112 | silentLogger.info({ file, projectName }); 113 | 114 | try { 115 | // Read and parse the project file 116 | silentLogger.info('Reading file: ' + filePath); 117 | const fileContent = await fs.readFile(filePath, 'utf8'); 118 | const jsonData = JSON.parse(fileContent); 119 | 120 | // Extract entities and relations 121 | const entities: DagNode = jsonData.entities || {}; 122 | 123 | // Process and write entities to NDJSON 124 | for (const [, entity] of Object.entries(entities)) { 125 | //skip non-actor or critic entities 126 | if (!entity.role) continue; 127 | 128 | // Add project field to the entity 129 | const enrichedEntity = { 130 | ...entity, 131 | //project context is used to infer the project name from the last item in the path. 132 | }; 133 | 134 | incrementProjectProcessed(projectName); 135 | // due to an issue with legacy project management, the data might be mixed in multiple project files. 136 | if (entity.projectContext) { 137 | enrichedEntity.projectContext = entity.projectContext; 138 | enrichedEntity.project = extractProjectName(entity.projectContext); 139 | } else if (projectName) { 140 | enrichedEntity.project = projectName; 141 | enrichedEntity.projectContext = `/this/is/fine/${projectName}`; 142 | } else { 143 | silentLogger.info({ entity, projectName, filePath }, 'Failed to process entity'); 144 | await fs.appendFile( 145 | failedEntitiesFilePath, 146 | JSON.stringify(enrichedEntity) + '\n', 147 | 'utf8', 148 | ); 149 | incrementProjectFailed(projectName); 150 | continue; 151 | } 152 | 153 | // Write to NDJSON file (one JSON object per line) 154 | silentLogger.info({ entity, projectName, filePath }, 'Writing entity to NDJSON file'); 155 | await fs.appendFile(ndjsonFilePath, JSON.stringify(enrichedEntity) + '\n', 'utf8'); 156 | } 157 | 158 | // Create a backup of the original project file 159 | const backupPath = path.resolve(backupDir, `${file}.backup.${Date.now()}`); 160 | silentLogger.info({ backupPath, filePath }, 'Creating backup of project file'); 161 | await fs.copyFile(filePath, backupPath); 162 | } catch (error) { 163 | logger.error(`Error processing project ${projectName}:`, error); 164 | } 165 | } 166 | 167 | if (fsSync.existsSync(backupPathFile)) { 168 | const backupStream = fsSync.createReadStream(backupPathFile); 169 | silentLogger.info({ backupPathFile }, 'Adding original NDJSON file entries'); 170 | const rl = createInterface({ 171 | input: backupStream, 172 | crlfDelay: Infinity, 173 | }); 174 | for await (const line of rl) { 175 | await fs.appendFile(ndjsonFilePath, line + '\n', 'utf8'); 176 | } 177 | backupStream.close(); 178 | rl.close(); 179 | silentLogger.info(`Successfully added original NDJSON file entries from ${backupPathFile}`); 180 | } 181 | 182 | silentLogger.info(`New NDJSON file created at: ${ndjsonFilePath}`); 183 | silentLogger.info(migrationMetrics, 'Migration metrics:'); 184 | logger.info('Migration metrics:'); 185 | logMigrationMetricsToAscii(migrationMetrics); 186 | logger.info('CodeLoops is now ready to use with new and improved knowledge graph management!'); 187 | } catch (error) { 188 | logger.error(error); 189 | logger.info('Migration failed.'); 190 | process.exit(1); 191 | } 192 | } 193 | 194 | // Run the migration 195 | migrateProjectFiles().catch((error) => { 196 | console.error('Unhandled error during migration:', error); 197 | process.exit(1); 198 | }); 199 | 200 | //output utils 201 | 202 | /// Define interfaces for the metrics structure 203 | interface ProjectMetrics { 204 | processed: number; 205 | failed: number; 206 | } 207 | 208 | interface MigrationMetrics { 209 | totalProcessed: number; 210 | totalFailed: number; 211 | projects: Record; 212 | } 213 | 214 | function logMigrationMetricsToAscii(metrics: MigrationMetrics): void { 215 | const projectTableData: string[][] = [ 216 | [chalk.cyan('Project'), chalk.cyan('Processed'), chalk.cyan('Failed')], // Headers 217 | ...Object.entries(metrics.projects).map(([projectName, stats]) => [ 218 | projectName, 219 | chalk.green(stats.processed.toString()), 220 | chalk.red(stats.failed.toString()), 221 | ]), 222 | ]; 223 | 224 | const projectTable = table(projectTableData, { 225 | border: { 226 | topBody: `─`, 227 | topJoin: `┬`, 228 | topLeft: `┌`, 229 | topRight: `┐`, 230 | bottomBody: `─`, 231 | bottomJoin: `┴`, 232 | bottomLeft: `└`, 233 | bottomRight: `┘`, 234 | bodyLeft: `│`, 235 | bodyRight: `│`, 236 | bodyJoin: `│`, 237 | joinLeft: `├`, 238 | joinRight: `┤`, 239 | joinBody: `─`, 240 | joinJoin: `┼`, 241 | }, 242 | }); 243 | 244 | const totalsTableData: string[][] = [ 245 | [chalk.cyan('Metric'), chalk.cyan('Value')], // Headers 246 | ['Total Processed', chalk.green(metrics.totalProcessed.toString())], 247 | ['Total Failed', chalk.red(metrics.totalFailed.toString())], 248 | ]; 249 | 250 | const totalsTable = table(totalsTableData, { 251 | border: { 252 | topBody: `─`, 253 | topJoin: `┬`, 254 | topLeft: `┌`, 255 | topRight: `┐`, 256 | bottomBody: `─`, 257 | bottomJoin: `┴`, 258 | bottomLeft: `└`, 259 | bottomRight: `┘`, 260 | bodyLeft: `│`, 261 | bodyRight: `│`, 262 | bodyJoin: `│`, 263 | joinLeft: `├`, 264 | joinRight: `┤`, 265 | joinBody: `─`, 266 | joinJoin: `┼`, 267 | }, 268 | }); 269 | 270 | // Log the message and both tables 271 | logger.info('\n' + projectTable + '\n' + totalsTable); 272 | } 273 | -------------------------------------------------------------------------------- /scripts/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # CodeLoops Quick Setup Script 4 | # This script automates the setup process for CodeLoops 5 | 6 | # Text formatting 7 | BOLD="\033[1m" 8 | GREEN="\033[0;32m" 9 | YELLOW="\033[0;33m" 10 | # use FE9A00 instead of BLUe 11 | BLUE="\033[0;34m" 12 | ORANGE="\033[0;38;5;166m" 13 | RED="\033[0;31m" 14 | NC="\033[0m" # No Color 15 | 16 | # Print header 17 | echo -e "${BOLD}${ORANGE} 18 | ██████╗ ██████╗ ██████╗ ███████╗██╗ ██████╗ ██████╗ ██████╗ ███████╗ 19 | ██╔════╝██╔═══██╗██╔══██╗██╔════╝██║ ██╔═══██╗██╔═══██╗██╔══██╗██╔════╝ 20 | ██║ ██║ ██║██║ ██║█████╗ ██║ ██║ ██║██║ ██║██████╔╝███████╗ 21 | ██║ ██║ ██║██║ ██║██╔══╝ ██║ ██║ ██║██║ ██║██╔═══╝ ╚════██║ 22 | ╚██████╗╚██████╔╝██████╔╝███████╗███████╗╚██████╔╝╚██████╔╝██║ ███████║ 23 | ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝ 24 | 25 | ${NC}${BOLD}Quick Setup Script${NC} 26 | " 27 | 28 | echo -e "${BOLD}This script will set up CodeLoops on your system.${NC}" 29 | echo -e "It will check for prerequisites, install dependencies, and configure the system.\n" 30 | 31 | # Function to check if a command exists 32 | command_exists() { 33 | command -v "$1" >/dev/null 2>&1 34 | } 35 | 36 | # Step 1: Check prerequisites 37 | echo -e "${BOLD}${BLUE}Step 1: Checking prerequisites...${NC}" 38 | 39 | # Check for Node.js 40 | if command_exists node; then 41 | NODE_VERSION=$(node -v) 42 | echo -e "✅ ${GREEN}Node.js is installed:${NC} $NODE_VERSION" 43 | 44 | # Check Node.js version 45 | NODE_MAJOR_VERSION=$(echo $NODE_VERSION | cut -d. -f1 | tr -d 'v') 46 | if [ "$NODE_MAJOR_VERSION" -lt 18 ]; then 47 | echo -e "⚠️ ${YELLOW}Warning: Node.js version 18+ is recommended. You have $NODE_VERSION${NC}" 48 | fi 49 | else 50 | echo -e "❌ ${RED}Node.js is not installed. Please install Node.js v18+ from https://nodejs.org/${NC}" 51 | exit 1 52 | fi 53 | 54 | # Check for Python 55 | if command_exists python3; then 56 | PYTHON_VERSION=$(python3 --version) 57 | echo -e "✅ ${GREEN}Python is installed:${NC} $PYTHON_VERSION" 58 | 59 | # Check Python version 60 | PYTHON_VERSION_NUM=$(echo $PYTHON_VERSION | cut -d' ' -f2) 61 | PYTHON_MAJOR=$(echo $PYTHON_VERSION_NUM | cut -d. -f1) 62 | PYTHON_MINOR=$(echo $PYTHON_VERSION_NUM | cut -d. -f2) 63 | 64 | if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 11 ]); then 65 | echo -e "⚠️ ${YELLOW}Warning: Python 3.11+ is recommended. You have $PYTHON_VERSION_NUM${NC}" 66 | fi 67 | else 68 | echo -e "❌ ${RED}Python 3 is not installed. Please install Python 3.11+ from https://www.python.org/${NC}" 69 | exit 1 70 | fi 71 | 72 | # Check for uv 73 | if command_exists uv; then 74 | UV_VERSION=$(uv --version) 75 | echo -e "✅ ${GREEN}uv is installed:${NC} $UV_VERSION" 76 | else 77 | echo -e "⚠️ ${YELLOW}uv is not installed. Installing uv...${NC}" 78 | 79 | # Install uv 80 | if command_exists pip3; then 81 | pip3 install uv 82 | if [ $? -eq 0 ]; then 83 | echo -e "✅ ${GREEN}uv has been installed successfully.${NC}" 84 | else 85 | echo -e "❌ ${RED}Failed to install uv. Please install it manually: https://docs.astral.sh/uv/getting-started/installation${NC}" 86 | exit 1 87 | fi 88 | else 89 | echo -e "❌ ${RED}pip3 is not available. Please install uv manually: https://docs.astral.sh/uv/getting-started/installation${NC}" 90 | exit 1 91 | fi 92 | fi 93 | 94 | echo -e "${GREEN}All prerequisites are satisfied!${NC}\n" 95 | 96 | # Step 2: Install Node.js dependencies 97 | echo -e "${BOLD}${BLUE}Step 2: Installing Node.js dependencies...${NC}" 98 | npm install 99 | if [ $? -eq 0 ]; then 100 | echo -e "✅ ${GREEN}Node.js dependencies installed successfully.${NC}\n" 101 | else 102 | echo -e "❌ ${RED}Failed to install Node.js dependencies. Please check the error messages above.${NC}" 103 | exit 1 104 | fi 105 | 106 | # Step 3: Set up Python virtual environments 107 | echo -e "${BOLD}${BLUE}Step 3: Setting up Python virtual environments...${NC}" 108 | 109 | # Setup Critic Agent 110 | echo -e "${BOLD}Setting up Critic Agent...${NC}" 111 | cd agents/critic 112 | uv sync 113 | if [ $? -eq 0 ]; then 114 | echo -e "✅ ${GREEN}Critic Agent dependencies installed successfully.${NC}" 115 | 116 | # Copy template files if they don't exist 117 | if [ ! -f fastagent.config.yaml ]; then 118 | cp fastagent.config.template.yaml fastagent.config.yaml 119 | echo -e "✅ ${GREEN}Created fastagent.config.yaml${NC}" 120 | fi 121 | 122 | if [ ! -f fastagent.secrets.yaml ]; then 123 | cp fastagent.secrets.template.yaml fastagent.secrets.yaml 124 | echo -e "✅ ${GREEN}Created fastagent.secrets.yaml${NC}" 125 | CRITIC_SECRETS_CREATED=true 126 | fi 127 | else 128 | echo -e "❌ ${RED}Failed to set up Critic Agent. Please check the error messages above.${NC}" 129 | exit 1 130 | fi 131 | 132 | # Setup Summarize Agent 133 | echo -e "\n${BOLD}Setting up Summarize Agent...${NC}" 134 | cd ../summarize 135 | uv sync 136 | if [ $? -eq 0 ]; then 137 | echo -e "✅ ${GREEN}Summarize Agent dependencies installed successfully.${NC}" 138 | 139 | # Copy template files if they don't exist 140 | if [ ! -f fastagent.config.yaml ]; then 141 | cp fastagent.config.template.yaml fastagent.config.yaml 142 | echo -e "✅ ${GREEN}Created fastagent.config.yaml${NC}" 143 | fi 144 | 145 | if [ ! -f fastagent.secrets.yaml ]; then 146 | cp fastagent.secrets.template.yaml fastagent.secrets.yaml 147 | echo -e "✅ ${GREEN}Created fastagent.secrets.yaml${NC}" 148 | SUMMARIZE_SECRETS_CREATED=true 149 | fi 150 | else 151 | echo -e "❌ ${RED}Failed to set up Summarize Agent. Please check the error messages above.${NC}" 152 | exit 1 153 | fi 154 | 155 | # Return to project root 156 | cd ../.. 157 | echo -e "${GREEN}Python virtual environments set up successfully!${NC}\n" 158 | 159 | # Step 4: Configure API keys 160 | echo -e "${BOLD}${BLUE}Step 4: Configuring API keys...${NC}" 161 | 162 | if [ "$CRITIC_SECRETS_CREATED" = true ] || [ "$SUMMARIZE_SECRETS_CREATED" = true ]; then 163 | echo -e "${YELLOW}You need to configure your API keys in the following files:${NC}" 164 | 165 | if [ "$CRITIC_SECRETS_CREATED" = true ]; then 166 | echo -e " - ${BOLD}agents/critic/fastagent.secrets.yaml${NC}" 167 | fi 168 | 169 | if [ "$SUMMARIZE_SECRETS_CREATED" = true ]; then 170 | echo -e " - ${BOLD}agents/summarize/fastagent.secrets.yaml${NC}" 171 | fi 172 | 173 | echo -e "\n${BOLD}Example configuration:${NC}" 174 | echo -e " anthropic:" 175 | echo -e " api_key: your-api-key-here" 176 | echo -e " # OR" 177 | echo -e " openai:" 178 | echo -e " api_key: your-api-key-here" 179 | 180 | echo -e "\n${YELLOW}Please edit these files before starting the server.${NC}" 181 | else 182 | echo -e "${GREEN}API key configuration files already exist.${NC}" 183 | echo -e "${YELLOW}If you need to update your API keys, edit the following files:${NC}" 184 | echo -e " - ${BOLD}agents/critic/fastagent.secrets.yaml${NC}" 185 | echo -e " - ${BOLD}agents/summarize/fastagent.secrets.yaml${NC}" 186 | fi 187 | 188 | echo -e "\n${GREEN}Setup completed successfully!${NC}\n" 189 | 190 | # Step 5: Provide instructions for starting the server 191 | echo -e "${BOLD}${BLUE}Step 5: Starting CodeLoops...${NC}" 192 | echo -e "${BOLD}To start the CodeLoops server, run:${NC}" 193 | echo -e " ${BOLD}npx -y tsx src${NC}" 194 | echo -e "\n${BOLD}Once started, you can use CodeLoops with your AI coding agent by:${NC}" 195 | echo -e "1. Configuring your agent to use the MCP server" 196 | echo -e "2. Using prompts like: ${BOLD}\"Use the CodeLoops tool to plan and implement...\"${NC}" 197 | echo -e "${YELLOW}Server not started. You can start it later with:${NC} ${BOLD}npx -y tsx src${NC}" -------------------------------------------------------------------------------- /scripts/test-quickstart.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # CodeLoops Quickstart Test Script 4 | # This script tests the setup script and CLI functionality 5 | 6 | # Text formatting 7 | BOLD="\033[1m" 8 | GREEN="\033[0;32m" 9 | YELLOW="\033[0;33m" 10 | BLUE="\033[0;34m" 11 | ORANGE="\033[0;38;5;166m" 12 | RED="\033[0;31m" 13 | NC="\033[0m" # No Color 14 | 15 | # Test results counter 16 | TESTS_PASSED=0 17 | TESTS_FAILED=0 18 | 19 | # Print header 20 | echo -e "${BOLD}${ORANGE} 21 | ██████╗ ██████╗ ██████╗ ███████╗██╗ ██████╗ ██████╗ ██████╗ ███████╗ 22 | ██╔════╝██╔═══██╗██╔══██╗██╔════╝██║ ██╔═══██╗██╔═══██╗██╔══██╗██╔════╝ 23 | ██║ ██║ ██║██║ ██║█████╗ ██║ ██║ ██║██║ ██║██████╔╝███████╗ 24 | ██║ ██║ ██║██║ ██║██╔══╝ ██║ ██║ ██║██║ ██║██╔═══╝ ╚════██║ 25 | ╚██████╗╚██████╔╝██████╔╝███████╗███████╗╚██████╔╝╚██████╔╝██║ ███████║ 26 | ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝ 27 | ${NC}${BOLD}Quickstart Test Script${NC} 28 | " 29 | 30 | echo -e "${BOLD}This script will test the CodeLoops quickstart functionality.${NC}" 31 | echo -e "It will verify the setup script and CLI functionality.\n" 32 | 33 | # Function to run a test and report results 34 | run_test() { 35 | local test_name="$1" 36 | local test_command="$2" 37 | local expected_pattern="$3" 38 | 39 | echo -e "\n${BOLD}${BLUE}Testing: ${test_name}${NC}" 40 | echo -e "${YELLOW}Command: ${test_command}${NC}" 41 | 42 | # Run the command and capture output 43 | output=$(eval "$test_command" 2>&1) 44 | exit_code=$? 45 | 46 | # Check if the output matches the expected pattern 47 | if echo "$output" | grep -q "$expected_pattern"; then 48 | echo -e "${GREEN}✅ PASSED: Output contains expected pattern${NC}" 49 | echo -e "${YELLOW}Expected pattern: ${expected_pattern}${NC}" 50 | TESTS_PASSED=$((TESTS_PASSED + 1)) 51 | else 52 | echo -e "${RED}❌ FAILED: Output does not contain expected pattern${NC}" 53 | echo -e "${YELLOW}Expected pattern: ${expected_pattern}${NC}" 54 | echo -e "${YELLOW}Actual output:${NC}" 55 | echo "$output" | head -n 10 56 | if [ $(echo "$output" | wc -l) -gt 10 ]; then 57 | echo -e "${YELLOW}... (output truncated)${NC}" 58 | fi 59 | TESTS_FAILED=$((TESTS_FAILED + 1)) 60 | fi 61 | 62 | # Check exit code 63 | if [ $exit_code -eq 0 ]; then 64 | echo -e "${GREEN}✅ PASSED: Command exited with code 0${NC}" 65 | else 66 | echo -e "${RED}❌ FAILED: Command exited with code ${exit_code}${NC}" 67 | TESTS_FAILED=$((TESTS_FAILED + 1)) 68 | fi 69 | } 70 | 71 | # Function to check if a file exists and has executable permissions 72 | check_executable() { 73 | local file_path="$1" 74 | 75 | echo -e "\n${BOLD}${BLUE}Checking: ${file_path}${NC}" 76 | 77 | if [ -f "$file_path" ]; then 78 | echo -e "${GREEN}✅ PASSED: File exists${NC}" 79 | TESTS_PASSED=$((TESTS_PASSED + 1)) 80 | else 81 | echo -e "${RED}❌ FAILED: File does not exist${NC}" 82 | TESTS_FAILED=$((TESTS_FAILED + 1)) 83 | return 1 84 | fi 85 | 86 | if [ -x "$file_path" ]; then 87 | echo -e "${GREEN}✅ PASSED: File is executable${NC}" 88 | TESTS_PASSED=$((TESTS_PASSED + 1)) 89 | else 90 | echo -e "${RED}❌ FAILED: File is not executable${NC}" 91 | TESTS_FAILED=$((TESTS_FAILED + 1)) 92 | return 1 93 | fi 94 | 95 | return 0 96 | } 97 | 98 | # Navigate to project root 99 | cd "$(dirname "$0")/.." || exit 1 100 | 101 | echo -e "${BOLD}${BLUE}Current directory: $(pwd)${NC}\n" 102 | 103 | # Test 1: Check if setup.sh exists and is executable 104 | check_executable "scripts/setup.sh" 105 | 106 | # Test 2: Test setup script prerequisites check 107 | run_test "Setup script prerequisites check" \ 108 | "bash scripts/setup.sh | head -n 20" \ 109 | "Checking prerequisites" 110 | 111 | # Test 3: Test package.json scripts 112 | run_test "Package.json scripts" \ 113 | "grep -A 10 '\"scripts\"' package.json" \ 114 | "\"setup\": \"bash scripts/setup.sh\"" 115 | 116 | # Test 4: Test QUICKSTART.md content 117 | run_test "QUICKSTART.md content" \ 118 | "grep -A 5 'Get Started in Seconds' QUICKSTART.md" \ 119 | "CodeLoops enhances AI coding agents" 120 | 121 | # Print test summary 122 | echo -e "\n${BOLD}${BLUE}Test Summary${NC}" 123 | echo -e "${GREEN}Tests passed: ${TESTS_PASSED}${NC}" 124 | echo -e "${RED}Tests failed: ${TESTS_FAILED}${NC}" 125 | 126 | if [ $TESTS_FAILED -eq 0 ]; then 127 | echo -e "\n${GREEN}${BOLD}All tests passed! The quickstart implementation is working correctly.${NC}" 128 | exit 0 129 | else 130 | echo -e "\n${RED}${BOLD}Some tests failed. Please fix the issues before finalizing the implementation.${NC}" 131 | exit 1 132 | fi 133 | -------------------------------------------------------------------------------- /src/agents/Actor.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid'; 2 | import { KnowledgeGraphManager, ArtifactRef, DagNode } from '../engine/KnowledgeGraph.ts'; 3 | import { ActorThinkInput } from '../engine/ActorCriticEngine.ts'; 4 | 5 | export class Actor { 6 | constructor(private readonly kg: KnowledgeGraphManager) {} 7 | 8 | async think( 9 | input: ActorThinkInput & { artifacts?: Partial[]; project: string; diff?: string }, 10 | ): Promise<{ node: DagNode }> { 11 | const { thought, tags, artifacts, project, projectContext, diff = '' } = input; 12 | 13 | //TODO: rework parents 14 | // const parents = (await this.kg.getHeads(project)).map((h) => h.id); 15 | 16 | const node: DagNode = { 17 | id: uuid(), 18 | project, 19 | thought, 20 | role: 'actor', 21 | parents: [], 22 | children: [], 23 | createdAt: '', // Will be set by appendEntity 24 | tags, 25 | artifacts: artifacts as ArtifactRef[], 26 | projectContext, 27 | diff, 28 | }; 29 | 30 | // Persist node 31 | await this.kg.appendEntity(node); 32 | 33 | return { node }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/agents/Critic.ts: -------------------------------------------------------------------------------- 1 | import { execa } from 'execa'; 2 | import { to } from 'await-to-js'; 3 | import { v4 as uuid } from 'uuid'; 4 | import { KnowledgeGraphManager, DagNode } from '../engine/KnowledgeGraph.ts'; 5 | import { getInstance as getLogger } from '../logger.ts'; 6 | import path from 'node:path'; 7 | import { fileURLToPath } from 'node:url'; 8 | import { z } from 'zod'; 9 | 10 | export const CriticSchema = { 11 | actorNodeId: z.string().describe('ID of the actor node to critique.'), 12 | }; 13 | 14 | /** 15 | * FILE_RX — detects a file path or filename with a wide range of extensions. 16 | * 17 | * • Accepts relative or nested paths: `src/utils/index.ts`, `./foo/bar.py` 18 | * • Case‑insensitive 19 | * • Captures extension families: 20 | * - Code: ts, tsx, js, jsx, mjs, cjs, py, go, java, kt, swift, rb, c, cpp, h, hpp, 21 | * cs, rs, php, scala, sh, bat, ps1 22 | * - Markup / styles: html, htm, css, scss, sass, less, xml, svg 23 | * - Config / data: json, yaml, yml, toml, sql, csv, lock 24 | * - Docs: md, markdown, txt, rst 25 | * - Assets: png, jpg, jpeg, gif, pdf 26 | */ 27 | const FILE_RX = 28 | /(?:^|\\s)([\\w./-]+\\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|java|kt|swift|rb|c|cpp|h|hpp|cs|rs|php|scala|sh|bat|ps1|html?|css|scss|sass|less|xml|svg|json|ya?ml|toml|sql|csv|lock|md|markdown|rst|txt|png|jpe?g|gif|pdf))(?:\\s|$)/i; 29 | 30 | function missingArtifactGuard(actorNode: DagNode): { needsFix: boolean; reason?: string } { 31 | const mentionsFile = FILE_RX.test(actorNode.thought); 32 | const hasArtifacts = !!actorNode.artifacts?.length; 33 | if (mentionsFile && !hasArtifacts) { 34 | return { 35 | needsFix: true, 36 | reason: 37 | 'Thought references a file but provided no artifacts array. Add an artifacts array with a durable link.', 38 | }; 39 | } 40 | return { needsFix: false }; 41 | } 42 | 43 | export class Critic { 44 | constructor(private readonly kg: KnowledgeGraphManager) {} 45 | 46 | async review({ 47 | actorNodeId, 48 | project, 49 | projectContext, 50 | }: { 51 | actorNodeId: string; 52 | project: string; 53 | projectContext: string; 54 | }): Promise { 55 | const target = await this.kg.getNode(actorNodeId); 56 | if (!target || (target as DagNode).role !== 'actor') 57 | throw new Error('invalid target for critic'); 58 | 59 | let verdict: DagNode['verdict'] = 'approved'; 60 | let reason: DagNode['verdictReason'] | undefined; 61 | 62 | if ((target as DagNode).thought.trim() === '') verdict = 'needs_revision'; 63 | const artifactGuard = missingArtifactGuard(target as DagNode); 64 | if (artifactGuard.needsFix) verdict = 'needs_revision'; 65 | if (artifactGuard.reason) reason = artifactGuard.reason; 66 | 67 | if (verdict === 'approved') { 68 | const __filename = fileURLToPath(import.meta.url); 69 | const __dirname = path.dirname(__filename); 70 | const criticDir = path.resolve(__dirname, '..', '..', 'agents', 'critic'); 71 | const targetJson = JSON.stringify(target); 72 | const [criticError, output] = await to( 73 | execa('uv', ['run', 'agent.py', '--quiet', '--agent', 'default', '--message', targetJson], { 74 | cwd: criticDir, 75 | }), 76 | ); 77 | 78 | if (criticError) { 79 | throw criticError; 80 | } 81 | try { 82 | const json = JSON.parse(output.stdout) as { 83 | verdict: DagNode['verdict']; 84 | verdictReason?: string; 85 | }; 86 | verdict = json.verdict; 87 | reason = json.verdictReason; 88 | } catch (err) { 89 | getLogger().error({ err }, 'Failed to parse JSON from uv mcp-server-fetch'); 90 | } 91 | } 92 | 93 | const criticNode: DagNode = { 94 | id: uuid(), 95 | project, 96 | thought: 97 | verdict === 'approved' 98 | ? '✔ Approved' 99 | : verdict === 'needs_revision' 100 | ? '✏ Needs revision' 101 | : '✗ Rejected', 102 | role: 'critic', 103 | verdict, 104 | ...(reason && { verdictReason: reason }), 105 | target: actorNodeId, 106 | parents: [actorNodeId], 107 | children: [], 108 | tags: [], 109 | artifacts: [], 110 | createdAt: '', // Will be set by appendEntity 111 | projectContext, 112 | }; 113 | 114 | // Update the target node's children to include this critic node 115 | if (target && !target.children.includes(criticNode.id)) { 116 | target.children.push(criticNode.id); 117 | // Update the target node in the knowledge graph 118 | await this.kg.appendEntity(target); 119 | } 120 | 121 | // Persist the critic node 122 | await this.kg.appendEntity(criticNode); 123 | 124 | return criticNode; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/agents/Summarize.ts: -------------------------------------------------------------------------------- 1 | import { execa } from 'execa'; 2 | import { to } from 'await-to-js'; 3 | import path from 'node:path'; 4 | import { getInstance as getLogger } from '../logger.ts'; 5 | import { fileURLToPath } from 'node:url'; 6 | import { v4 as uuid } from 'uuid'; 7 | import { DagNode, KnowledgeGraphManager, SummaryNode } from '../engine/KnowledgeGraph.ts'; 8 | 9 | /** 10 | * SummarizationAgent provides an interface to the Python-based summarization agent. 11 | * It handles serialization/deserialization of node data and processes the agent's response. 12 | * It also manages the summarization logic for the knowledge graph. 13 | */ 14 | export class SummarizationAgent { 15 | private readonly agentPath: string; 16 | 17 | // Number of nodes after which to trigger summarization 18 | private static SUMMARIZATION_THRESHOLD = 20; 19 | 20 | /** 21 | * Creates a new SummarizationAgent. 22 | * @param knowledgeGraph The knowledge graph manager instance 23 | * @param pythonCommand Python command to use (defaults to 'uv') 24 | * @param pythonArgs Additional arguments for the Python command (defaults to ['run']) 25 | */ 26 | constructor( 27 | private readonly knowledgeGraph: KnowledgeGraphManager, 28 | private readonly pythonCommand: string = 'uv', 29 | private readonly pythonArgs: string[] = ['run'], 30 | ) { 31 | // Get the path to the summarize agent directory 32 | const __filename = fileURLToPath(import.meta.url); 33 | const __dirname = path.dirname(__filename); 34 | this.agentPath = path.resolve(__dirname, '..', '..', 'agents', 'summarize'); 35 | } 36 | 37 | /** 38 | * Summarizes a segment of nodes from the knowledge graph. 39 | * @param nodes Array of DagNode objects to summarize 40 | * @returns A promise that resolves to an object containing the summary text and any error 41 | */ 42 | async summarize(nodes: DagNode[]): Promise<{ summary: string; error?: string }> { 43 | try { 44 | // Serialize the nodes to JSON 45 | const nodesJson = JSON.stringify(nodes); 46 | 47 | // Log input for debugging 48 | getLogger().info({ nodesJson }, 'Summarization agent input'); 49 | 50 | // Call the Python agent using execa 51 | const [execError, output] = await to( 52 | execa(this.pythonCommand, [...this.pythonArgs, 'agent.py', '--quiet', '--summarize'], { 53 | cwd: this.agentPath, 54 | input: nodesJson, 55 | }), 56 | ); 57 | 58 | // Handle execution errors 59 | if (execError) { 60 | getLogger().error({ execError }, 'Summarization agent execution error'); 61 | return { 62 | summary: '', 63 | error: `Failed to execute summarization agent: ${execError.message}`, 64 | }; 65 | } 66 | 67 | // Handle stderr output 68 | if (output.stderr) { 69 | getLogger().error({ stderr: output.stderr }, 'Summarization agent stderr output'); 70 | } 71 | 72 | // Log raw output for debugging 73 | getLogger().info({ rawOutput: output.stdout }, 'Summarization agent raw output'); 74 | 75 | // Parse the response with improved error handling 76 | let response; 77 | try { 78 | response = JSON.parse(output.stdout.trim()); 79 | } catch (parseError) { 80 | const err = parseError as Error; 81 | getLogger().error( 82 | { parseError, rawOutput: output.stdout }, 83 | 'Failed to parse summarization agent response', 84 | ); 85 | return { summary: '', error: `Failed to parse JSON response: ${err.message}` }; 86 | } 87 | 88 | // Check if response has the expected format 89 | if (response.error) { 90 | return { summary: '', error: response.error }; 91 | } 92 | 93 | if (response.summary) { 94 | return { summary: response.summary }; 95 | } 96 | 97 | // If the response doesn't match the expected format 98 | if (typeof response !== 'object' || response === null) { 99 | return { 100 | summary: output.stdout.trim(), 101 | error: 'Response format not recognized, using raw output as summary', 102 | }; 103 | } 104 | 105 | // Fallback for unexpected valid JSON object response 106 | return { 107 | summary: '', 108 | error: 'Unexpected response format: no summary or error provided', 109 | }; 110 | } catch (error) { 111 | const err = error as Error; 112 | return { 113 | summary: '', 114 | error: `Unexpected error during summarization: ${err.message}`, 115 | }; 116 | } 117 | } 118 | 119 | /** 120 | * Checks if summarization is needed and triggers it if necessary. 121 | * This should be called after adding new nodes to the graph. 122 | */ 123 | async checkAndTriggerSummarization({ 124 | project, 125 | projectContext, 126 | }: { 127 | project: string; 128 | projectContext: string; 129 | }): Promise { 130 | const nodes = await this.knowledgeGraph.resume({ 131 | project, 132 | limit: SummarizationAgent.SUMMARIZATION_THRESHOLD, 133 | }); 134 | 135 | const lastSummaryIndex = nodes.findIndex((node) => node.role === 'summary'); 136 | const nodesToSummarize = nodes.slice(lastSummaryIndex + 1); 137 | 138 | // Only summarize branches that have enough nodes 139 | if (nodesToSummarize.length >= SummarizationAgent.SUMMARIZATION_THRESHOLD) { 140 | await this.createSummary({ 141 | project, 142 | projectContext, 143 | nodes: nodesToSummarize, 144 | }); 145 | } 146 | } 147 | 148 | /** 149 | * Creates a summary for a segment of nodes. 150 | * @param nodes Nodes to summarize 151 | * @throws Error if summarization fails 152 | */ 153 | async createSummary({ 154 | nodes, 155 | projectContext, 156 | project, 157 | }: { 158 | nodes: DagNode[]; 159 | projectContext: string; 160 | project: string; 161 | }): Promise { 162 | if (!nodes || nodes.length === 0) { 163 | throw new Error('Cannot create summary: No nodes provided'); 164 | } 165 | 166 | getLogger().info(`[createSummary] Creating summary for ${nodes.length} nodes`); 167 | 168 | const result = await this.summarize(nodes); 169 | 170 | // Check for errors in the summarization result 171 | if (result.error) { 172 | getLogger().error({ error: result.error }, `[createSummary] Summarization agent error:`); 173 | throw new Error(`Summarization failed: ${result.error}`); 174 | } 175 | 176 | // Validate the summary content 177 | if (!result.summary || result.summary.trim() === '') { 178 | getLogger().error(`[createSummary] Summarization agent returned empty summary`); 179 | throw new Error('Summarization failed: Empty summary returned'); 180 | } 181 | 182 | // Create a summary node 183 | const summaryNode: SummaryNode = { 184 | id: uuid(), 185 | project, 186 | thought: result.summary, 187 | role: 'summary', 188 | parents: [nodes[nodes.length - 1].id], // Link to the newest node in the segment 189 | children: [], 190 | createdAt: '', // Will be set by appendEntity 191 | projectContext, 192 | summarizedSegment: nodes.map((node) => node.id), 193 | tags: ['summary'], 194 | artifacts: [], 195 | }; 196 | 197 | getLogger().info(`[createSummary] Created summary node with ID ${summaryNode.id}`); 198 | 199 | // Persist the summary node 200 | await this.knowledgeGraph.appendEntity(summaryNode); 201 | 202 | // Update the last node to include the summary node in its children 203 | const lastNode = nodes[nodes.length - 1]; 204 | if (lastNode && !lastNode.children.includes(summaryNode.id)) { 205 | lastNode.children.push(summaryNode.id); 206 | await this.knowledgeGraph.appendEntity(lastNode); 207 | } 208 | 209 | return summaryNode; 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { fileURLToPath } from 'node:url'; 3 | 4 | // ----------------------------------------------------------------------------- 5 | // Path Configuration ---------------------------------------------------------- 6 | // ----------------------------------------------------------------------------- 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | export const dataDir = path.resolve(__dirname, '..', 'data'); 11 | -------------------------------------------------------------------------------- /src/engine/ActorCriticEngine.ts: -------------------------------------------------------------------------------- 1 | import { Critic } from '../agents/Critic.ts'; 2 | import { Actor } from '../agents/Actor.ts'; 3 | import { KnowledgeGraphManager, type DagNode, FILE_REF } from './KnowledgeGraph.ts'; 4 | import { SummarizationAgent } from '../agents/Summarize.ts'; 5 | import { z } from 'zod'; 6 | // ----------------------------------------------------------------------------- 7 | // Actor–Critic engine ---------------------------------------------------------- 8 | // ----------------------------------------------------------------------------- 9 | 10 | const THOUGHT_DESCRIPTION = ` 11 | Add a new thought node to the knowledge‑graph. 12 | 13 | • Use for any creative / planning step, requirement capture, task break‑down, etc. 14 | • **Always include at least one 'tag'** so future searches can find this node 15 | – e.g. requirement, task, risk, design, definition. 16 | • **If your thought references a file you just created or modified**, list it in 17 | the 'artifacts' array so the graph stores a durable link. 18 | • Think of 'tags' + 'artifacts' as the breadcrumbs that future you (or another 19 | agent) will follow to avoid duplicate work or forgotten decisions. 20 | `.trim(); 21 | 22 | export const ActorThinkSchema = { 23 | thought: z.string().describe(THOUGHT_DESCRIPTION), 24 | 25 | projectContext: z 26 | .string() 27 | .describe( 28 | 'Full path to the currently open directory in the code editor. Used to infer the project name from the last item in the path.', 29 | ), 30 | 31 | tags: z 32 | .array(z.string()) 33 | .min(1, 'Add at least one semantic tag – requirement, task, risk, design …') 34 | .describe('Semantic categories used for later search and deduping.'), 35 | 36 | /** Actual files produced or updated by this step.*/ 37 | artifacts: z 38 | .array(FILE_REF) 39 | .describe( 40 | 'Declare the file set this thought will affect so the critic can ' + 41 | 'verify coverage before code is written.' + 42 | 'graph has durable pointers to the exact revision.', 43 | ), 44 | }; 45 | 46 | export const ActorThinkSchemaZodObject = z.object(ActorThinkSchema); 47 | export type ActorThinkInput = z.infer; 48 | 49 | export class ActorCriticEngine { 50 | constructor( 51 | private readonly kg: KnowledgeGraphManager, 52 | private readonly critic: Critic, 53 | private readonly actor: Actor, 54 | private readonly summarizationAgent: SummarizationAgent, 55 | ) {} 56 | 57 | // Use the centralized extractProjectName function from utils 58 | /* --------------------------- public API --------------------------- */ 59 | /** 60 | * Adds a new thought node to the knowledge graph and automatically triggers 61 | * critic review 62 | * 63 | * @param input The actor thought input 64 | * @returns Either the actor node (if no review was triggered) or the critic node (if review was triggered) 65 | */ 66 | async actorThink(input: ActorThinkInput & { project: string; diff?: string }): Promise { 67 | // Actor.think will handle project switching based on projectContext 68 | const { node } = await this.actor.think(input); 69 | 70 | const criticNode = await this.criticReview({ 71 | actorNodeId: node.id, 72 | projectContext: input.projectContext, 73 | project: input.project, 74 | }); 75 | 76 | return criticNode; 77 | } 78 | 79 | /** 80 | * Manually triggers a critic review for a specific actor node. 81 | * 82 | * NOTE: In most cases, you don't need to call this directly as actorThink 83 | * automatically triggers critic reviews when appropriate. 84 | * 85 | * This method is primarily useful for: 86 | * - Manual intervention in the workflow 87 | * - Forcing a review of a specific previous node 88 | * - Debugging or testing purposes 89 | * 90 | * @param actorNodeId The ID of the actor node to review 91 | * @param projectContext The project context for the review 92 | * @returns The critic node 93 | */ 94 | async criticReview({ 95 | actorNodeId, 96 | projectContext, 97 | project, 98 | }: { 99 | actorNodeId: string; 100 | projectContext: string; 101 | project: string; 102 | }): Promise { 103 | const criticNode = await this.critic.review({ actorNodeId, projectContext, project }); 104 | 105 | // Trigger summarization check after adding a critic node 106 | await this.summarizationAgent.checkAndTriggerSummarization({ 107 | project, 108 | projectContext, 109 | }); 110 | 111 | return criticNode; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/engine/KnowledgeGraph.delete.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { KnowledgeGraphManager, DagNode } from './KnowledgeGraph.js'; 3 | import fs from 'node:fs/promises'; 4 | import path from 'node:path'; 5 | import { v4 as uuid } from 'uuid'; 6 | import os from 'node:os'; 7 | import { createLogger, setGlobalLogger, getInstance as getLogger } from '../logger.js'; 8 | 9 | const logger = createLogger({ withFile: false, withDevStdout: true }); 10 | setGlobalLogger(logger); 11 | 12 | describe('KnowledgeGraphManager - Soft Delete Functions', () => { 13 | let kg: KnowledgeGraphManager; 14 | let testDataDir: string; 15 | let logFilePath: string; 16 | let deletedLogFilePath: string; 17 | let backupDir: string; 18 | 19 | beforeEach(async () => { 20 | // Create a unique test directory 21 | testDataDir = path.join(os.tmpdir(), `kg-delete-test-${uuid()}`); 22 | await fs.mkdir(testDataDir, { recursive: true }); 23 | logFilePath = path.join(testDataDir, 'knowledge_graph.ndjson'); 24 | deletedLogFilePath = path.join(testDataDir, 'knowledge_graph.deleted.ndjson'); 25 | backupDir = path.join(testDataDir, 'backup'); 26 | 27 | // Create a KnowledgeGraphManager instance with custom paths 28 | kg = new KnowledgeGraphManager(getLogger()); 29 | // @ts-expect-error - Accessing private properties for testing 30 | kg.logFilePath = logFilePath; 31 | // @ts-expect-error - Accessing private properties for testing 32 | kg.deletedLogFilePath = deletedLogFilePath; 33 | // @ts-expect-error - Accessing private properties for testing 34 | kg.backupDir = backupDir; 35 | await kg.init(); 36 | }); 37 | 38 | afterEach(async () => { 39 | try { 40 | await fs.rm(testDataDir, { recursive: true, force: true }); 41 | } catch (error) { 42 | console.error('Error cleaning up test directory:', error); 43 | } 44 | }); 45 | 46 | // Helper function to create a test node 47 | const createTestNode = ( 48 | project: string, 49 | role: 'actor' | 'critic' | 'summary' = 'actor', 50 | parents: string[] = [], 51 | summarizedSegment?: string[], 52 | ): DagNode => ({ 53 | id: uuid(), 54 | project, 55 | projectContext: `/path/to/${project}`, 56 | thought: `Test thought for ${project}`, 57 | role, 58 | parents, 59 | children: [], 60 | createdAt: '', 61 | tags: ['test-tag'], 62 | artifacts: [], 63 | ...(role === 'summary' && summarizedSegment ? { summarizedSegment } : {}), 64 | }); 65 | 66 | describe('findDependentNodes', () => { 67 | it('should find nodes that depend on target nodes', async () => { 68 | const project = 'test-project'; 69 | 70 | // Create a chain: nodeA -> nodeB -> nodeC 71 | const nodeA = createTestNode(project); 72 | const nodeB = createTestNode(project, 'actor', [nodeA.id]); 73 | const nodeC = createTestNode(project, 'actor', [nodeB.id]); 74 | 75 | await kg.appendEntity(nodeA); 76 | await kg.appendEntity(nodeB); 77 | await kg.appendEntity(nodeC); 78 | 79 | // Find dependents of nodeA 80 | const dependentsMap = await kg.findDependentNodes([nodeA.id], project); 81 | 82 | expect(dependentsMap.has(nodeA.id)).toBe(true); 83 | const dependents = dependentsMap.get(nodeA.id)!; 84 | expect(dependents).toHaveLength(1); 85 | expect(dependents[0].id).toBe(nodeB.id); 86 | }); 87 | 88 | it('should return empty dependents for nodes with no children', async () => { 89 | const project = 'test-project'; 90 | const nodeA = createTestNode(project); 91 | await kg.appendEntity(nodeA); 92 | 93 | const dependentsMap = await kg.findDependentNodes([nodeA.id], project); 94 | 95 | expect(dependentsMap.has(nodeA.id)).toBe(true); 96 | expect(dependentsMap.get(nodeA.id)).toHaveLength(0); 97 | }); 98 | }); 99 | 100 | describe('findAffectedSummaryNodes', () => { 101 | it('should find summary nodes that reference deleted nodes', async () => { 102 | const project = 'test-project'; 103 | 104 | // Create regular nodes 105 | const nodeA = createTestNode(project); 106 | const nodeB = createTestNode(project); 107 | await kg.appendEntity(nodeA); 108 | await kg.appendEntity(nodeB); 109 | 110 | // Create summary node that references both 111 | const summaryNode = createTestNode(project, 'summary', [], [nodeA.id, nodeB.id]); 112 | await kg.appendEntity(summaryNode); 113 | 114 | // Find affected summaries when deleting nodeA 115 | const affectedSummaries = await kg.findAffectedSummaryNodes([nodeA.id], project); 116 | 117 | expect(affectedSummaries).toHaveLength(1); 118 | expect(affectedSummaries[0].id).toBe(summaryNode.id); 119 | }); 120 | 121 | it('should return empty array when no summaries are affected', async () => { 122 | const project = 'test-project'; 123 | const nodeA = createTestNode(project); 124 | await kg.appendEntity(nodeA); 125 | 126 | const affectedSummaries = await kg.findAffectedSummaryNodes([nodeA.id], project); 127 | 128 | expect(affectedSummaries).toHaveLength(0); 129 | }); 130 | }); 131 | 132 | describe('softDeleteNodes', () => { 133 | it('should successfully soft delete nodes', async () => { 134 | const project = 'test-project'; 135 | 136 | // Create test nodes 137 | const nodeA = createTestNode(project); 138 | const nodeB = createTestNode(project); 139 | await kg.appendEntity(nodeA); 140 | await kg.appendEntity(nodeB); 141 | 142 | // Soft delete nodeA 143 | const result = await kg.softDeleteNodes([nodeA.id], project, 'test deletion', 'test-user'); 144 | 145 | expect(result.deletedNodes).toHaveLength(1); 146 | expect(result.deletedNodes[0].id).toBe(nodeA.id); 147 | expect(result.deletedNodes[0].deletedReason).toBe('test deletion'); 148 | expect(result.deletedNodes[0].deletedBy).toBe('test-user'); 149 | expect(result.deletedNodes[0].deletedAt).toBeDefined(); 150 | expect(result.backupPath).toContain('backup'); 151 | }); 152 | 153 | it('should create backup before deletion', async () => { 154 | const project = 'test-project'; 155 | const nodeA = createTestNode(project); 156 | await kg.appendEntity(nodeA); 157 | 158 | const result = await kg.softDeleteNodes([nodeA.id], project); 159 | 160 | // Check backup was created 161 | expect(result.backupPath).toBeDefined(); 162 | const backupExists = await fs.access(result.backupPath).then(() => true, () => false); 163 | expect(backupExists).toBe(true); 164 | }); 165 | 166 | it('should write deleted nodes to deleted log', async () => { 167 | const project = 'test-project'; 168 | const nodeA = createTestNode(project); 169 | await kg.appendEntity(nodeA); 170 | 171 | await kg.softDeleteNodes([nodeA.id], project, 'test deletion'); 172 | 173 | // Check deleted log was created and contains our node 174 | const deletedLogExists = await fs.access(deletedLogFilePath).then(() => true, () => false); 175 | expect(deletedLogExists).toBe(true); 176 | 177 | const deletedContent = await fs.readFile(deletedLogFilePath, 'utf-8'); 178 | expect(deletedContent).toContain(nodeA.id); 179 | expect(deletedContent).toContain('test deletion'); 180 | }); 181 | 182 | it('should rebuild main graph without deleted nodes', async () => { 183 | const project = 'test-project'; 184 | 185 | // Create nodes 186 | const nodeA = createTestNode(project); 187 | const nodeB = createTestNode(project); 188 | const nodeC = createTestNode(project, 'actor', [nodeA.id]); // nodeC depends on nodeA 189 | 190 | await kg.appendEntity(nodeA); 191 | await kg.appendEntity(nodeB); 192 | await kg.appendEntity(nodeC); 193 | 194 | // Delete nodeA 195 | await kg.softDeleteNodes([nodeA.id], project); 196 | 197 | // Check remaining nodes 198 | const remainingNodes = await kg.allDagNodes(project); 199 | expect(remainingNodes).toHaveLength(2); 200 | expect(remainingNodes.find(n => n.id === nodeA.id)).toBeUndefined(); 201 | expect(remainingNodes.find(n => n.id === nodeB.id)).toBeDefined(); 202 | expect(remainingNodes.find(n => n.id === nodeC.id)).toBeDefined(); 203 | 204 | // Check that nodeC no longer has nodeA as parent 205 | const updatedNodeC = remainingNodes.find(n => n.id === nodeC.id); 206 | expect(updatedNodeC?.parents).not.toContain(nodeA.id); 207 | }); 208 | 209 | it('should handle multiple nodes deletion', async () => { 210 | const project = 'test-project'; 211 | 212 | // Create nodes 213 | const nodeA = createTestNode(project); 214 | const nodeB = createTestNode(project); 215 | const nodeC = createTestNode(project); 216 | 217 | await kg.appendEntity(nodeA); 218 | await kg.appendEntity(nodeB); 219 | await kg.appendEntity(nodeC); 220 | 221 | // Delete multiple nodes 222 | const result = await kg.softDeleteNodes([nodeA.id, nodeB.id], project, 'bulk delete'); 223 | 224 | expect(result.deletedNodes).toHaveLength(2); 225 | const remainingNodes = await kg.allDagNodes(project); 226 | expect(remainingNodes).toHaveLength(1); 227 | expect(remainingNodes[0].id).toBe(nodeC.id); 228 | }); 229 | 230 | it('should preserve nodes from other projects', async () => { 231 | const project1 = 'project-1'; 232 | const project2 = 'project-2'; 233 | 234 | // Create nodes in different projects 235 | const nodeA = createTestNode(project1); 236 | const nodeB = createTestNode(project2); 237 | 238 | await kg.appendEntity(nodeA); 239 | await kg.appendEntity(nodeB); 240 | 241 | // Delete node from project1 242 | await kg.softDeleteNodes([nodeA.id], project1); 243 | 244 | // Check project2 node is preserved 245 | const project2Nodes = await kg.allDagNodes(project2); 246 | expect(project2Nodes).toHaveLength(1); 247 | expect(project2Nodes[0].id).toBe(nodeB.id); 248 | 249 | // Check project1 node is gone 250 | const project1Nodes = await kg.allDagNodes(project1); 251 | expect(project1Nodes).toHaveLength(0); 252 | }); 253 | 254 | it('should find affected summary nodes correctly', async () => { 255 | const project = 'test-project'; 256 | 257 | // Create regular nodes 258 | const nodeA = createTestNode(project); 259 | const nodeB = createTestNode(project); 260 | await kg.appendEntity(nodeA); 261 | await kg.appendEntity(nodeB); 262 | 263 | // Create summary node 264 | const summaryNode = createTestNode(project, 'summary', [], [nodeA.id, nodeB.id]); 265 | await kg.appendEntity(summaryNode); 266 | 267 | // Delete nodeA 268 | const result = await kg.softDeleteNodes([nodeA.id], project); 269 | 270 | expect(result.affectedSummaries).toHaveLength(1); 271 | expect(result.affectedSummaries[0].id).toBe(summaryNode.id); 272 | }); 273 | }); 274 | 275 | describe('edge cases', () => { 276 | it('should handle deletion of non-existent nodes gracefully', async () => { 277 | const project = 'test-project'; 278 | const nonExistentId = uuid(); 279 | 280 | // This should not throw, just return empty results 281 | const result = await kg.softDeleteNodes([nonExistentId], project); 282 | 283 | expect(result.deletedNodes).toHaveLength(0); 284 | expect(result.backupPath).toBeDefined(); // Backup should still be created 285 | }); 286 | 287 | it('should handle empty node IDs array', async () => { 288 | const project = 'test-project'; 289 | 290 | const result = await kg.softDeleteNodes([], project); 291 | 292 | expect(result.deletedNodes).toHaveLength(0); 293 | expect(result.backupPath).toBeDefined(); 294 | }); 295 | 296 | it('should handle deletion when graph is empty', async () => { 297 | const project = 'test-project'; 298 | const nodeId = uuid(); 299 | 300 | const result = await kg.softDeleteNodes([nodeId], project); 301 | 302 | expect(result.deletedNodes).toHaveLength(0); 303 | expect(result.backupPath).toBeDefined(); 304 | }); 305 | }); 306 | }); -------------------------------------------------------------------------------- /src/engine/KnowledgeGraph.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { KnowledgeGraphManager, DagNode } from './KnowledgeGraph.js'; 3 | import fs from 'node:fs/promises'; 4 | import path from 'node:path'; 5 | import { v4 as uuid } from 'uuid'; 6 | import os from 'node:os'; 7 | import { createLogger, setGlobalLogger, getInstance as getLogger } from '../logger.js'; 8 | const logger = createLogger({ withFile: false, withDevStdout: true }); 9 | setGlobalLogger(logger); 10 | 11 | describe('KnowledgeGraphManager', () => { 12 | let kg: KnowledgeGraphManager; 13 | let testDataDir: string; 14 | let logFilePath: string; 15 | 16 | // Create a temporary directory for test data 17 | beforeEach(async () => { 18 | // Create a unique test directory 19 | testDataDir = path.join(os.tmpdir(), `kg-test-${uuid()}`); 20 | await fs.mkdir(testDataDir, { recursive: true }); 21 | logFilePath = path.join(testDataDir, 'knowledge_graph.ndjson'); 22 | 23 | // Create a KnowledgeGraphManager instance with a custom log file path 24 | kg = new KnowledgeGraphManager(getLogger()); 25 | // Set the log file path directly using a non-exported property 26 | // @ts-expect-error - Accessing private property for testing 27 | kg.logFilePath = logFilePath; 28 | await kg.init(); 29 | }); 30 | 31 | // Clean up after each test 32 | afterEach(async () => { 33 | try { 34 | await fs.rm(testDataDir, { recursive: true, force: true }); 35 | } catch (error) { 36 | console.error('Error cleaning up test directory:', error); 37 | } 38 | }); 39 | 40 | // Helper function to create a test node 41 | const createTestNode = ( 42 | project: string, 43 | role: 'actor' | 'critic' | 'summary' = 'actor', 44 | parents: string[] = [], 45 | ): DagNode => ({ 46 | id: uuid(), 47 | project, 48 | projectContext: `/path/to/${project}`, 49 | thought: `Test thought for ${project}`, 50 | role, 51 | parents, 52 | children: [], 53 | createdAt: '', 54 | tags: ['test-tag'], 55 | artifacts: [], 56 | }); 57 | 58 | describe('appendEntity', () => { 59 | it('should successfully append a node to the log file', async () => { 60 | const testNode = createTestNode('test-project'); 61 | await kg.appendEntity(testNode); 62 | 63 | // Read the log file and verify the node was written 64 | const content = await fs.readFile(logFilePath, 'utf-8'); 65 | expect(content).toContain(testNode.id); 66 | expect(content).toContain(testNode.project); 67 | expect(content).toContain(testNode.thought); 68 | }); 69 | 70 | it('should set the createdAt timestamp when appending', async () => { 71 | const testNode = createTestNode('test-project'); 72 | expect(testNode.createdAt).toBe(''); 73 | 74 | await kg.appendEntity(testNode); 75 | expect(testNode.createdAt).not.toBe(''); 76 | 77 | // Verify it's a valid ISO date string 78 | expect(() => new Date(testNode.createdAt)).not.toThrow(); 79 | }); 80 | 81 | it('should not allow cycles in the graph', async () => { 82 | // Create a chain of nodes A -> B -> C 83 | const nodeA = createTestNode('test-project'); 84 | await kg.appendEntity(nodeA); 85 | 86 | const nodeB = createTestNode('test-project', 'actor', [nodeA.id]); 87 | await kg.appendEntity(nodeB); 88 | 89 | const nodeC = createTestNode('test-project', 'actor', [nodeB.id]); 90 | await kg.appendEntity(nodeC); 91 | 92 | // Try to create a cycle by making A depend on C 93 | // Since we can't directly test wouldCreateCycle (it's private), 94 | // we'll verify that the graph maintains its integrity 95 | const nodeD = createTestNode('test-project', 'actor', [nodeC.id]); 96 | await kg.appendEntity(nodeD); 97 | 98 | // Verify the graph structure 99 | const nodes = await kg.resume({ project: 'test-project' }); 100 | expect(nodes.length).toBe(4); 101 | expect(nodes[nodes.length - 1].id).toBe(nodeD.id); 102 | }); 103 | }); 104 | 105 | describe('getNode', () => { 106 | it('should retrieve a node by id and project', async () => { 107 | const testNode = createTestNode('test-project'); 108 | await kg.appendEntity(testNode); 109 | 110 | const retrievedNode = await kg.getNode(testNode.id); 111 | expect(retrievedNode).toBeDefined(); 112 | expect(retrievedNode?.id).toBe(testNode.id); 113 | expect(retrievedNode?.thought).toBe(testNode.thought); 114 | }); 115 | 116 | it('should return undefined for non-existent nodes', async () => { 117 | const nonExistentId = uuid(); 118 | const result = await kg.getNode(nonExistentId); 119 | expect(result).toBeUndefined(); 120 | }); 121 | }); 122 | 123 | describe('resume', () => { 124 | it('should return recent nodes', async () => { 125 | // Create multiple nodes 126 | const nodes = []; 127 | for (let i = 0; i < 10; i++) { 128 | const node = createTestNode('test-project'); 129 | node.thought = `Node ${i}`; 130 | await kg.appendEntity(node); 131 | nodes.push(node); 132 | } 133 | 134 | // Get the most recent nodes 135 | const result = await kg.resume({ project: 'test-project', limit: 5 }); 136 | 137 | // Check that we have nodes 138 | expect(result.length).toBeGreaterThan(0); 139 | 140 | // Verify that the nodes are from our test set 141 | // The exact order might vary based on implementation details 142 | for (const node of result) { 143 | expect(node.thought).toMatch(/^Node \d+$/); 144 | } 145 | }); 146 | 147 | it('should return all nodes if limit is not specified', async () => { 148 | // Create 3 nodes 149 | for (let i = 0; i < 3; i++) { 150 | const node = createTestNode('test-project'); 151 | node.thought = `Node ${i}`; 152 | await kg.appendEntity(node); 153 | } 154 | 155 | // Get all nodes (default behavior) 156 | const result = await kg.resume({ project: 'test-project' }); 157 | expect(result.length).toBe(3); 158 | }); 159 | }); 160 | 161 | describe('export', () => { 162 | it('should filter nodes by tag', async () => { 163 | // Create nodes with different tags 164 | const nodeA = createTestNode('test-project'); 165 | nodeA.tags = ['tag-a']; 166 | await kg.appendEntity(nodeA); 167 | 168 | const nodeB = createTestNode('test-project'); 169 | nodeB.tags = ['tag-b']; 170 | await kg.appendEntity(nodeB); 171 | 172 | const nodeC = createTestNode('test-project'); 173 | nodeC.tags = ['tag-a', 'tag-c']; 174 | await kg.appendEntity(nodeC); 175 | 176 | // Filter by tag-a 177 | const result = await kg.export({ 178 | project: 'test-project', 179 | filterFn: (n: DagNode) => n.tags?.includes('tag-a') ?? false, 180 | }); 181 | expect(result.length).toBe(2); 182 | expect(result.map((n: DagNode) => n.id).sort()).toEqual([nodeA.id, nodeC.id].sort()); 183 | }); 184 | 185 | it('should apply custom filter functions', async () => { 186 | // Create nodes with different roles 187 | const actorNode = createTestNode('test-project', 'actor'); 188 | await kg.appendEntity(actorNode); 189 | 190 | const criticNode = createTestNode('test-project', 'critic'); 191 | await kg.appendEntity(criticNode); 192 | 193 | const summaryNode = createTestNode('test-project', 'summary'); 194 | await kg.appendEntity(summaryNode); 195 | 196 | // Filter by role = 'critic' 197 | const result = await kg.export({ 198 | project: 'test-project', 199 | filterFn: (node: DagNode) => node.role === 'critic', 200 | }); 201 | 202 | expect(result.length).toBe(1); 203 | expect(result[0].id).toBe(criticNode.id); 204 | }); 205 | 206 | it('should respect the limit parameter', async () => { 207 | // Create 10 nodes 208 | for (let i = 0; i < 10; i++) { 209 | const node = createTestNode('test-project'); 210 | node.thought = `Node ${i}`; 211 | await kg.appendEntity(node); 212 | } 213 | 214 | // Get nodes with a limit 215 | const result = await kg.export({ project: 'test-project', limit: 3 }); 216 | 217 | // Check that we have nodes (may not be exactly 3 due to implementation details) 218 | expect(result.length).toBeGreaterThan(0); 219 | expect(result.length).toBeLessThanOrEqual(10); // Should not exceed total nodes 220 | }); 221 | }); 222 | 223 | describe('listProjects', () => { 224 | it('should list all projects with nodes in the graph', async () => { 225 | // Create nodes for different projects 226 | await kg.appendEntity(createTestNode('project-a')); 227 | await kg.appendEntity(createTestNode('project-b')); 228 | await kg.appendEntity(createTestNode('project-c')); 229 | 230 | // List projects 231 | const projects = await kg.listProjects(); 232 | expect(projects.length).toBe(3); 233 | expect(projects.sort()).toEqual(['project-a', 'project-b', 'project-c'].sort()); 234 | }); 235 | 236 | it('should return an empty array if no nodes exist', async () => { 237 | // No nodes added 238 | const projects = await kg.listProjects(); 239 | expect(projects).toEqual([]); 240 | }); 241 | }); 242 | }); 243 | -------------------------------------------------------------------------------- /src/engine/KnowledgeGraph.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import { lock, unlock } from 'proper-lockfile'; 3 | import * as fsSync from 'node:fs'; 4 | import path from 'node:path'; 5 | import { z } from 'zod'; 6 | import readline from 'node:readline'; 7 | import { dataDir } from '../config.ts'; 8 | import { CodeLoopsLogger } from '../logger.ts'; 9 | 10 | // ----------------------------------------------------------------------------- 11 | // Interfaces & Schemas -------------------------------------------------------- 12 | // ----------------------------------------------------------------------------- 13 | 14 | export interface WithProjectContext { 15 | project: string; 16 | projectContext: string; 17 | } 18 | 19 | export const FILE_REF = z.object({ 20 | name: z.string(), // human label ("UML‑AuthSeq") 21 | uri: z.string().optional(), // optional external link or S3 key 22 | /** Absolute or repo‑relative path, e.g. "QuickRecorder/CameraOverlay.swift" */ 23 | path: z.string(), 24 | /** Optional hash to lock content for provenance */ 25 | hash: z.string().optional(), 26 | /** Optional MIME, e.g. "text/x-swift" */ 27 | contentType: z.string().optional(), 28 | }); 29 | export type ArtifactRef = z.infer; 30 | 31 | // Schema for validating DagNode entries 32 | export const DagNodeSchema = z.object({ 33 | id: z.string(), 34 | project: z.string(), 35 | projectContext: z.string(), 36 | thought: z.string(), 37 | role: z.enum(['actor', 'critic', 'summary']), 38 | createdAt: z.string().datetime(), 39 | parents: z.array(z.string()), 40 | children: z.array(z.string()), 41 | verdict: z.enum(['approved', 'needs_revision', 'reject']).optional(), 42 | verdictReason: z.string().optional(), 43 | verdictReferences: z.array(z.string()).optional(), 44 | target: z.string().optional(), 45 | summarizedSegment: z.array(z.string()).optional(), 46 | artifacts: z.array(FILE_REF).optional(), 47 | tags: z.array(z.string()).optional(), 48 | diff: z.string().optional(), 49 | }); 50 | 51 | export interface DagNode extends WithProjectContext { 52 | id: string; 53 | thought: string; 54 | role: 'actor' | 'critic' | 'summary'; 55 | verdict?: 'approved' | 'needs_revision' | 'reject'; 56 | verdictReason?: string; 57 | verdictReferences?: string[]; 58 | target?: string; // nodeId this criticises 59 | parents: string[]; 60 | children: string[]; 61 | createdAt: string; // ISO timestamp 62 | summarizedSegment?: string[]; // IDs of nodes summarized (for summary nodes) 63 | artifacts?: ArtifactRef[]; 64 | tags?: string[]; 65 | diff?: string; // The changes introduced for this step 66 | } 67 | 68 | export interface SummaryNode extends DagNode { 69 | role: 'summary'; 70 | summarizedSegment: string[]; // IDs of nodes summarized 71 | } 72 | 73 | export interface DeletedNode extends DagNode { 74 | deletedAt: string; // ISO timestamp 75 | deletedReason?: string; 76 | deletedBy?: string; 77 | } 78 | 79 | export interface DependentNode { 80 | id: string; 81 | thought: string; 82 | role: string; 83 | tags?: string[]; 84 | } 85 | 86 | // ----------------------------------------------------------------------------- 87 | // KnowledgeGraphManager ------------------------------------------------------- 88 | // ----------------------------------------------------------------------------- 89 | 90 | export class KnowledgeGraphManager { 91 | private logFilePath: string = path.resolve(dataDir, 'knowledge_graph.ndjson'); 92 | private deletedLogFilePath: string = path.resolve(dataDir, 'knowledge_graph.deleted.ndjson'); 93 | private backupDir: string = path.resolve(dataDir, 'backup'); 94 | private logger: CodeLoopsLogger; 95 | 96 | constructor(logger: CodeLoopsLogger) { 97 | this.logger = logger; 98 | } 99 | 100 | async init() { 101 | this.logger.info(`[KnowledgeGraphManager] Initializing from ${this.logFilePath}`); 102 | await this.loadLog(); 103 | } 104 | 105 | private async loadLog() { 106 | if (!(await fs.stat(this.logFilePath).catch(() => null))) { 107 | this.logger.info(`[KnowledgeGraphManager] Creating new log file at ${this.logFilePath}`); 108 | await fs.mkdir(path.dirname(this.logFilePath), { recursive: true }); 109 | await fs.writeFile(this.logFilePath, ''); 110 | return; 111 | } 112 | } 113 | 114 | private parseDagNode(line: string): DagNode | null { 115 | try { 116 | const parsed = JSON.parse(line); 117 | const validated = DagNodeSchema.parse(parsed); 118 | return validated as DagNode; 119 | } catch (err) { 120 | this.logger.error({ err, line }, 'Invalid DagNode entry'); 121 | return null; 122 | } 123 | } 124 | 125 | async appendEntity(entity: DagNode, retries = 3) { 126 | if (await this.wouldCreateCycle(entity)) { 127 | throw new Error(`Appending node ${entity.id} would create a cycle`); 128 | } 129 | 130 | entity.createdAt = new Date().toISOString(); 131 | const line = JSON.stringify(entity) + '\n'; 132 | let err: Error | null = null; 133 | 134 | for (let attempt = 1; attempt <= retries; attempt++) { 135 | try { 136 | await lock(this.logFilePath, { retries: 0 }); 137 | await fs.appendFile(this.logFilePath, line, 'utf8'); 138 | return; 139 | } catch (e: unknown) { 140 | err = e as Error; 141 | this.logger.warn({ err, attempt }, `Retry ${attempt} failed appending entity`); 142 | if (attempt === retries) break; 143 | await new Promise((resolve) => setTimeout(resolve, 100 * attempt)); 144 | } finally { 145 | try { 146 | await unlock(this.logFilePath); 147 | } catch (unlockErr) { 148 | this.logger.error({ err: unlockErr }, 'Failed to unlock file'); 149 | } 150 | } 151 | } 152 | 153 | this.logger.error({ err }, 'Error appending entity after retries'); 154 | throw err; 155 | } 156 | 157 | private async wouldCreateCycle(entity: DagNode): Promise { 158 | const visited = new Set(); 159 | async function dfs(id: string, manager: KnowledgeGraphManager): Promise { 160 | if (visited.has(id)) return true; 161 | visited.add(id); 162 | const node = await manager.getNode(id); 163 | if (!node) return false; 164 | for (const childId of node.children) { 165 | if (childId === entity.id || (await dfs(childId, manager))) return true; 166 | } 167 | return false; 168 | } 169 | for (const parentId of entity.parents) { 170 | if (await dfs(parentId, this)) return true; 171 | } 172 | return false; 173 | } 174 | 175 | async getNode(id: string): Promise { 176 | const fileStream = fsSync.createReadStream(this.logFilePath); 177 | const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); 178 | try { 179 | for await (const line of rl) { 180 | const entry = this.parseDagNode(line); 181 | if (entry?.id === id) { 182 | return entry; 183 | } 184 | } 185 | return undefined; 186 | } finally { 187 | rl.close(); 188 | fileStream.close(); 189 | } 190 | } 191 | 192 | async *streamDagNodes(project: string): AsyncGenerator { 193 | const fileStream = fsSync.createReadStream(this.logFilePath); 194 | const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); 195 | try { 196 | for await (const line of rl) { 197 | const node = this.parseDagNode(line); 198 | if (node?.project === project) { 199 | yield node; 200 | } 201 | } 202 | } finally { 203 | rl.close(); 204 | fileStream.close(); 205 | } 206 | } 207 | 208 | async allDagNodes(project: string): Promise { 209 | const nodes: DagNode[] = []; 210 | for await (const node of this.streamDagNodes(project)) { 211 | nodes.push(node); 212 | } 213 | return nodes; 214 | } 215 | 216 | async resume({ 217 | project, 218 | limit = 5, 219 | includeDiffs = 'latest' 220 | }: { 221 | project: string; 222 | limit?: number; 223 | includeDiffs?: 'all' | 'latest' | 'none'; 224 | }): Promise { 225 | const nodes = await this.export({ project, limit }); 226 | 227 | // Handle diff inclusion based on includeDiffs parameter 228 | if (includeDiffs === 'none') { 229 | // Remove diff from all nodes 230 | return nodes.map(node => ({ ...node, diff: undefined })); 231 | } else if (includeDiffs === 'latest') { 232 | // Only include diff for the most recent node (last in array) 233 | return nodes.map((node, index) => ({ 234 | ...node, 235 | diff: index === nodes.length - 1 ? node.diff : undefined 236 | })); 237 | } 238 | 239 | // includeDiffs === 'all' - return nodes as is with all diffs 240 | return nodes; 241 | } 242 | 243 | async export({ 244 | project, 245 | filterFn, 246 | limit, 247 | }: { 248 | project: string; 249 | filterFn?: (node: DagNode) => boolean; 250 | limit?: number; 251 | }): Promise { 252 | const nodes: DagNode[] = []; 253 | const fileStream = fsSync.createReadStream(this.logFilePath); 254 | const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); 255 | try { 256 | for await (const line of rl) { 257 | const node = this.parseDagNode(line); 258 | if (!node || node.project !== project) continue; 259 | if (filterFn && !filterFn(node)) continue; 260 | nodes.push(node); 261 | if (limit && nodes.length > limit) nodes.shift(); 262 | } 263 | return nodes; 264 | } finally { 265 | rl.close(); 266 | fileStream.close(); 267 | } 268 | } 269 | 270 | async listProjects(): Promise { 271 | const projects = new Set(); 272 | const fileStream = fsSync.createReadStream(this.logFilePath); 273 | const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); 274 | try { 275 | for await (const line of rl) { 276 | const entry = this.parseDagNode(line); 277 | if (entry?.project && !projects.has(entry.project)) { 278 | projects.add(entry.project); 279 | } 280 | } 281 | return Array.from(projects); 282 | } finally { 283 | rl.close(); 284 | fileStream.close(); 285 | } 286 | } 287 | 288 | // ----------------------------------------------------------------------------- 289 | // Soft Delete Methods --------------------------------------------------------- 290 | // ----------------------------------------------------------------------------- 291 | 292 | async findDependentNodes(nodeIds: string[], project: string): Promise> { 293 | const dependentsMap = new Map(); 294 | nodeIds.forEach(id => dependentsMap.set(id, [])); 295 | 296 | for await (const node of this.streamDagNodes(project)) { 297 | for (const nodeId of nodeIds) { 298 | if (node.parents.includes(nodeId)) { 299 | const dependents = dependentsMap.get(nodeId) || []; 300 | dependents.push({ 301 | id: node.id, 302 | thought: node.thought, 303 | role: node.role, 304 | tags: node.tags, 305 | }); 306 | dependentsMap.set(nodeId, dependents); 307 | } 308 | } 309 | } 310 | 311 | return dependentsMap; 312 | } 313 | 314 | async findAffectedSummaryNodes(nodeIds: string[], project: string): Promise { 315 | const affectedSummaries: SummaryNode[] = []; 316 | 317 | for await (const node of this.streamDagNodes(project)) { 318 | if (node.role === 'summary' && node.summarizedSegment) { 319 | const hasDeletedNode = node.summarizedSegment.some(id => nodeIds.includes(id)); 320 | if (hasDeletedNode) { 321 | affectedSummaries.push(node as SummaryNode); 322 | } 323 | } 324 | } 325 | 326 | return affectedSummaries; 327 | } 328 | 329 | private async createBackup(): Promise { 330 | await fs.mkdir(this.backupDir, { recursive: true }); 331 | const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); 332 | const backupPath = path.join(this.backupDir, `knowledge_graph_${timestamp}.ndjson`); 333 | 334 | await fs.copyFile(this.logFilePath, backupPath); 335 | this.logger.info(`[KnowledgeGraphManager] Created backup at ${backupPath}`); 336 | 337 | return backupPath; 338 | } 339 | 340 | private async appendToDeletedLog(nodes: DeletedNode[]): Promise { 341 | const lines = nodes.map(node => JSON.stringify(node) + '\n').join(''); 342 | await fs.appendFile(this.deletedLogFilePath, lines, 'utf8'); 343 | } 344 | 345 | async softDeleteNodes( 346 | nodeIds: string[], 347 | project: string, 348 | reason?: string, 349 | deletedBy?: string 350 | ): Promise<{ 351 | deletedNodes: DeletedNode[]; 352 | backupPath: string; 353 | affectedSummaries: SummaryNode[]; 354 | }> { 355 | // Create backup first 356 | const backupPath = await this.createBackup(); 357 | 358 | // Find nodes to delete 359 | const nodesToDelete: DagNode[] = []; 360 | const remainingNodes: DagNode[] = []; 361 | 362 | for await (const node of this.streamDagNodes(project)) { 363 | if (nodeIds.includes(node.id)) { 364 | nodesToDelete.push(node); 365 | } else { 366 | remainingNodes.push(node); 367 | } 368 | } 369 | 370 | // Convert to deleted nodes 371 | const deletedNodes: DeletedNode[] = nodesToDelete.map(node => ({ 372 | ...node, 373 | deletedAt: new Date().toISOString(), 374 | deletedReason: reason, 375 | deletedBy: deletedBy, 376 | })); 377 | 378 | // Append to deleted log 379 | await this.appendToDeletedLog(deletedNodes); 380 | 381 | // Find affected summary nodes before rebuilding 382 | const affectedSummaries = await this.findAffectedSummaryNodes(nodeIds, project); 383 | 384 | // Rebuild the main graph without deleted nodes 385 | await this.rebuildGraphWithoutDeleted(project, nodeIds); 386 | 387 | this.logger.info(`[KnowledgeGraphManager] Soft deleted ${deletedNodes.length} nodes from project ${project}`); 388 | 389 | return { 390 | deletedNodes, 391 | backupPath, 392 | affectedSummaries, 393 | }; 394 | } 395 | 396 | private async rebuildGraphWithoutDeleted(project: string, deletedNodeIds: string[]): Promise { 397 | // Read all nodes from all projects 398 | const allNodes: DagNode[] = []; 399 | const fileStream = fsSync.createReadStream(this.logFilePath); 400 | const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); 401 | 402 | try { 403 | for await (const line of rl) { 404 | const node = this.parseDagNode(line); 405 | if (node && !deletedNodeIds.includes(node.id)) { 406 | // Update parent/child references to exclude deleted nodes 407 | node.parents = node.parents.filter(id => !deletedNodeIds.includes(id)); 408 | node.children = node.children.filter(id => !deletedNodeIds.includes(id)); 409 | allNodes.push(node); 410 | } 411 | } 412 | } finally { 413 | rl.close(); 414 | fileStream.close(); 415 | } 416 | 417 | // Write back all non-deleted nodes 418 | const tempPath = `${this.logFilePath}.tmp`; 419 | const lines = allNodes.map(node => JSON.stringify(node) + '\n').join(''); 420 | await fs.writeFile(tempPath, lines, 'utf8'); 421 | 422 | // Atomic replace 423 | await fs.rename(tempPath, this.logFilePath); 424 | } 425 | } 426 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { createLogger } from './logger.js'; 2 | import { stdio } from './server/stdio.js'; 3 | import { http } from './server/http.js'; 4 | 5 | // ----------------------------------------------------------------------------- 6 | // CLI Configuration ----------------------------------------------------------- 7 | // ----------------------------------------------------------------------------- 8 | 9 | interface ServerConfig { 10 | protocol: 'stdio' | 'http'; 11 | port?: number; 12 | host?: string; 13 | } 14 | 15 | const parseArgs = (): ServerConfig => { 16 | const args = process.argv.slice(2); 17 | const config: ServerConfig = { 18 | protocol: 'stdio', // Default to stdio for backward compatibility 19 | port: 3000, 20 | host: '0.0.0.0', 21 | }; 22 | 23 | for (let i = 0; i < args.length; i++) { 24 | const arg = args[i]; 25 | switch (arg) { 26 | case '--http': 27 | config.protocol = 'http'; 28 | break; 29 | case '--stdio': 30 | config.protocol = 'stdio'; 31 | break; 32 | case '--port': { 33 | const port = parseInt(args[++i]); 34 | if (isNaN(port)) { 35 | throw new Error('Invalid port number'); 36 | } 37 | config.port = port; 38 | break; 39 | } 40 | case '--host': 41 | config.host = args[++i]; 42 | break; 43 | case '--help': 44 | case '-h': 45 | console.log(` 46 | CodeLoops MCP Server 47 | 48 | Usage: npm start [options] 49 | 50 | Options: 51 | --stdio Use stdio transport (default) 52 | --http Use HTTP transport 53 | --port HTTP server port (default: 3000) 54 | --host HTTP server host (default: 0.0.0.0) 55 | -h, --help Show this help message 56 | 57 | Examples: 58 | npm start # Use stdio transport 59 | npm start -- --http # Use HTTP transport on default port 3000 60 | npm start -- --http --port 8080 # Use HTTP transport on port 8080 61 | `); 62 | process.exit(0); 63 | break; 64 | default: 65 | if (arg.startsWith('--')) { 66 | throw new Error(`Unknown option: ${arg}`); 67 | } 68 | break; 69 | } 70 | } 71 | 72 | return config; 73 | }; 74 | 75 | // ----------------------------------------------------------------------------- 76 | // Main Entry Point ------------------------------------------------------------ 77 | // ----------------------------------------------------------------------------- 78 | const logger = createLogger(); 79 | 80 | /** 81 | * Main entry point for the CodeLoops MCP server. 82 | */ 83 | async function main() { 84 | let config: ServerConfig; 85 | 86 | try { 87 | config = parseArgs(); 88 | } catch (error) { 89 | console.error('Error parsing arguments:', error); 90 | process.exit(1); 91 | } 92 | 93 | logger.info(`Starting CodeLoops MCP server with ${config.protocol} transport...`); 94 | 95 | try { 96 | switch (config.protocol) { 97 | case 'stdio': 98 | await stdio.buildServer({ logger }); 99 | logger.info('CodeLoops MCP server running on stdio'); 100 | break; 101 | case 'http': 102 | if (!config.port) { 103 | throw new Error('Port is required for HTTP transport'); 104 | } 105 | await http.buildServer({ logger, port: config.port }); 106 | logger.info(`CodeLoops MCP HTTP server running on ${config.host}:${config.port}`); 107 | break; 108 | default: 109 | throw new Error(`Unsupported protocol: ${config.protocol}`); 110 | } 111 | } catch (error) { 112 | logger.error({ error }, 'Failed to start server'); 113 | process.exit(1); 114 | } 115 | } 116 | 117 | main().catch((err) => { 118 | logger.error({ err }, 'Fatal error in main'); 119 | process.exit(1); 120 | }); 121 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { type Logger, pino } from 'pino'; 2 | import fs from 'node:fs'; 3 | import path from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = path.dirname(__filename); 8 | 9 | export type CodeLoopsLogger = Logger; 10 | let globalLogger: CodeLoopsLogger | null = null; 11 | 12 | interface CreateLoggerOptions { 13 | withDevStdout?: boolean; 14 | withFile?: boolean; 15 | sync?: boolean; 16 | setGlobal?: boolean; 17 | } 18 | 19 | const logsDir = path.resolve(__dirname, '../logs'); 20 | const logFile = path.join(logsDir, 'codeloops.log'); 21 | if (!fs.existsSync(logsDir)) { 22 | fs.mkdirSync(logsDir, { recursive: true }); 23 | } 24 | /** 25 | * Creates and returns a new pino logger instance with the given options. 26 | * Also sets the global logger if not already set. 27 | */ 28 | export function createLogger(options?: CreateLoggerOptions): CodeLoopsLogger { 29 | // Ensure logs directory exists 30 | const targets: pino.TransportTargetOptions[] = []; 31 | if (options?.withFile) { 32 | targets.push({ 33 | target: 'pino-roll', 34 | options: { 35 | file: logFile, 36 | //TODO: make this all configurable by the user 37 | frequency: 'daily', 38 | limit: { 39 | count: 14, // 14 days of log retention 40 | }, 41 | }, 42 | }); 43 | } 44 | if (options?.withDevStdout) { 45 | targets.push({ 46 | target: 'pino-pretty', 47 | options: { 48 | destination: 1, 49 | }, 50 | }); 51 | } 52 | const transports = pino.transport({ 53 | targets, 54 | ...(options ?? {}), 55 | }); 56 | const logger = pino(transports); 57 | if (options?.setGlobal && !globalLogger) { 58 | globalLogger = logger; 59 | } 60 | return logger; 61 | } 62 | 63 | /** 64 | * Returns the global singleton logger instance. If not created, creates with default options. 65 | */ 66 | export function getInstance(options?: CreateLoggerOptions): CodeLoopsLogger { 67 | if (!globalLogger) { 68 | createLogger({ withFile: true, ...options, setGlobal: true }); 69 | } 70 | return globalLogger!; 71 | } 72 | 73 | export function setGlobalLogger(logger: CodeLoopsLogger) { 74 | globalLogger = logger; 75 | } 76 | -------------------------------------------------------------------------------- /src/server/http.ts: -------------------------------------------------------------------------------- 1 | import Fastify, { FastifyInstance } from 'fastify'; 2 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; 3 | import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; 4 | import { FastifyRequest, FastifyReply } from 'fastify'; 5 | import { nanoid } from 'nanoid'; 6 | import { CodeLoopsLogger } from '../logger.js'; 7 | import { createMcpServerInstance } from './index.js'; 8 | import { to } from 'await-to-js'; 9 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; 10 | import { registerTools } from './tools.js'; 11 | 12 | declare module 'fastify' { 13 | interface FastifyRequest { 14 | currentTransport?: StreamableHTTPServerTransport; 15 | sessionId?: string; 16 | } 17 | } 18 | 19 | export const buildServer = async ({ logger, port }: { logger: CodeLoopsLogger; port: number }) => { 20 | logger.info('Building CodeLoops MCP HTTP server'); 21 | 22 | const fastify = Fastify({ 23 | logger: { 24 | level: 'info', 25 | }, 26 | }); 27 | 28 | // Scopes the onRequest hook to the mcp routes in this register handler 29 | await fastify.register(async (fastify) => { 30 | const routePrefix = '/api'; 31 | // Store transports for each session type 32 | const transports = { 33 | streamable: {} as Record, 34 | sse: {} as Record, 35 | }; 36 | 37 | // Handle POST requests for client-to-server communication 38 | fastify.post(`${routePrefix}/mcp`, async (request, reply) => { 39 | const sessionId = request.headers['mcp-session-id']; 40 | let transport: StreamableHTTPServerTransport; 41 | 42 | request.log.info('Processing onRequest transport configuration'); 43 | 44 | if (sessionId && transports.streamable[sessionId as string]) { 45 | request.log.info(`Using existing transport for session ID: ${sessionId}`); 46 | transport = transports.streamable[sessionId as string]; 47 | } else if (!sessionId && isInitializeRequest(request.body)) { 48 | request.log.info('Initializing new transport'); 49 | transport = new StreamableHTTPServerTransport({ 50 | sessionIdGenerator: () => nanoid(), 51 | onsessioninitialized: (sessionId) => { 52 | transports.streamable[sessionId] = transport; 53 | }, 54 | }); 55 | 56 | transport.onclose = () => { 57 | request.log.info('MCP HTTP server closed'); 58 | if (transport.sessionId) { 59 | request.log.info('Removing transport for session ID: ' + transport.sessionId); 60 | delete transports.streamable[transport.sessionId]; 61 | } 62 | }; 63 | 64 | request.log.info('Creating MCP server instance'); 65 | const server = createMcpServerInstance(); 66 | 67 | registerTools({ server }); 68 | 69 | request.currentTransport = transport; 70 | 71 | request.log.info('Connecting MCP server to transport'); 72 | await server.connect(transport); 73 | request.log.info('MCP server connected and ready to receive requests'); 74 | } else { 75 | request.log.error('No valid session ID provided'); 76 | return reply.status(400).send({ 77 | jsonrpc: '2.0', 78 | error: { 79 | code: -32000, 80 | message: 'Bad Request: No valid session ID provided', 81 | }, 82 | id: null, 83 | }); 84 | } 85 | //if for some reason this does not exist, error 86 | if (!transport) { 87 | request.log.error('No transport found for session ID'); 88 | return reply.status(400).send('Invalid or missing session ID'); 89 | } 90 | 91 | request.log.info({ body: request.body }, 'Processing MCP request'); 92 | await transport.handleRequest(request.raw, reply.raw, request.body); 93 | }); 94 | 95 | // Reusable handler for GET and DELETE 96 | /** 97 | * Handles session requests by verifying the session ID and utilizing 98 | * the respective transport to process the request. Responds with an error 99 | * if the session ID is invalid or missing. 100 | * 101 | * @param {FastifyRequest} request - The incoming Fastify request object. 102 | * @param {FastifyReply} reply - The Fastify reply object to send responses. 103 | */ 104 | const handleSessionRequest = async (request: FastifyRequest, reply: FastifyReply) => { 105 | const sessionId = request.headers['mcp-session-id']; 106 | if (!sessionId || !transports.streamable[sessionId as string]) { 107 | request.log.error('No transport found for session ID'); 108 | return reply.status(400).send('Invalid or missing session ID'); 109 | } 110 | 111 | const transport = transports.streamable[sessionId as string]; 112 | await transport.handleRequest(request.raw, reply.raw); 113 | }; 114 | 115 | // Handle GET for SSE 116 | fastify.get(`${routePrefix}/mcp`, handleSessionRequest); 117 | 118 | // Handle DELETE for session termination 119 | fastify.delete(`${routePrefix}/mcp`, handleSessionRequest); 120 | 121 | //add legacy routes 122 | await addLegacyRoutes({ transports, fastify }); 123 | }); 124 | 125 | const [err, address] = await to(fastify.listen({ port, host: '0.0.0.0' })); 126 | 127 | if (err) { 128 | fastify.log.error(err); 129 | process.exit(1); 130 | } 131 | 132 | fastify.log.info(`Server listening at ${address}`); 133 | }; 134 | 135 | export const addLegacyRoutes = async ({ 136 | transports, 137 | fastify, 138 | }: { 139 | transports: { 140 | streamable: Record; 141 | sse: Record; 142 | }; 143 | fastify: FastifyInstance; 144 | }) => { 145 | //creating dedicated server instance for legacy routes 146 | const server = createMcpServerInstance(); 147 | 148 | registerTools({ server }); 149 | 150 | fastify.get('/sse', async (request, reply) => { 151 | const transport = new SSEServerTransport('/messages', reply.raw); 152 | transports.sse[transport.sessionId as string] = transport; 153 | reply.raw.on('close', () => { 154 | delete transports.sse[transport.sessionId as string]; 155 | }); 156 | await server.connect(transport); 157 | }); 158 | fastify.post('/messages', async (request, reply) => { 159 | // 160 | const sessionId = (request?.query as { sessionId?: string })?.sessionId as string; 161 | const transport = transports.sse[sessionId]; 162 | if (!transport) { 163 | reply.status(400).send('Invalid or missing session ID'); 164 | return; 165 | } 166 | await transport.handlePostMessage(request.raw, reply.raw, request.body); 167 | }); 168 | }; 169 | 170 | export const http = { 171 | buildServer, 172 | }; -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 2 | import pkg from '../../package.json' with { type: 'json' }; 3 | 4 | export const createMcpServerInstance = () => { 5 | const server = new McpServer({ 6 | name: 'codeloops', 7 | version: pkg.version, 8 | }); 9 | 10 | return server; 11 | }; -------------------------------------------------------------------------------- /src/server/stdio.ts: -------------------------------------------------------------------------------- 1 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 2 | import { CodeLoopsLogger } from '../logger.js'; 3 | import { createMcpServerInstance } from './index.js'; 4 | import { registerTools } from './tools.js'; 5 | 6 | export const buildServer = async ({ 7 | logger, 8 | }: { 9 | logger: CodeLoopsLogger; 10 | }) => { 11 | const server = createMcpServerInstance(); 12 | 13 | registerTools({ server }); 14 | 15 | logger.info('Initializing stdio transport'); 16 | const transport = new StdioServerTransport(); 17 | 18 | logger.info('Connecting MCP server to transport'); 19 | await server.connect(transport); 20 | 21 | logger.info('MCP server connected and ready to receive requests'); 22 | }; 23 | 24 | export const stdio = { 25 | buildServer, 26 | }; -------------------------------------------------------------------------------- /src/server/tools.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 2 | import { z } from 'zod'; 3 | import { ActorCriticEngine, ActorThinkSchema } from '../engine/ActorCriticEngine.js'; 4 | import { KnowledgeGraphManager } from '../engine/KnowledgeGraph.js'; 5 | import { Critic } from '../agents/Critic.js'; 6 | import { Actor } from '../agents/Actor.js'; 7 | import { SummarizationAgent } from '../agents/Summarize.js'; 8 | import { CodeLoopsLogger, getInstance as getLogger, setGlobalLogger } from '../logger.js'; 9 | import { extractProjectName } from '../utils/project.js'; 10 | import { getGitDiff } from '../utils/git.js'; 11 | 12 | // Shared dependencies interface 13 | interface ToolDependencies { 14 | logger: CodeLoopsLogger; 15 | kg: KnowledgeGraphManager; 16 | engine: ActorCriticEngine; 17 | runOnce: (project: string) => void; 18 | } 19 | 20 | // Utility function to load project 21 | const loadProjectOrThrow = async ({ 22 | logger, 23 | args, 24 | onProjectLoad, 25 | }: { 26 | logger: CodeLoopsLogger; 27 | args: { projectContext: string }; 28 | onProjectLoad: (project: string) => void; 29 | }) => { 30 | const projectName = extractProjectName(args.projectContext); 31 | if (!projectName) { 32 | logger.error({ projectContext: args.projectContext }, 'Invalid projectContext'); 33 | throw new Error(`Invalid projectContext: ${args.projectContext}`); 34 | } 35 | onProjectLoad(projectName); 36 | return projectName; 37 | }; 38 | 39 | export const createDependencies = async (): Promise => { 40 | const logger = getLogger(); 41 | 42 | // Create KnowledgeGraphManager 43 | const kg = new KnowledgeGraphManager(logger); 44 | await kg.init(); 45 | 46 | // Create SummarizationAgent with KnowledgeGraphManager 47 | const summarizationAgent = new SummarizationAgent(kg); 48 | 49 | // Create other dependencies 50 | const critic = new Critic(kg); 51 | const actor = new Actor(kg); 52 | 53 | // Create ActorCriticEngine with all dependencies 54 | const engine = new ActorCriticEngine(kg, critic, actor, summarizationAgent); 55 | 56 | const runOnce = (project: string) => { 57 | const child = logger.child({ project }); 58 | setGlobalLogger(child); 59 | }; 60 | 61 | return { logger, kg, engine, runOnce }; 62 | }; 63 | 64 | export const registerTools = ({ server }: { server: McpServer }) => { 65 | // This will be called from each transport, so we need to initialize dependencies here 66 | let deps: ToolDependencies | null = null; 67 | 68 | const getDeps = async (): Promise => { 69 | if (!deps) { 70 | deps = await createDependencies(); 71 | } 72 | return deps; 73 | }; 74 | 75 | const ACTOR_THINK_DESCRIPTION = ` 76 | Add a new thought node to the CodeLoops knowledge graph to plan, execute, or document coding tasks. 77 | 78 | **Purpose**: This is the **primary tool** for interacting with the actor-critic system. It records your work, triggers critic reviews when needed, and guides you through iterative development. **You must call 'actor_think' iteratively** after every significant action to ensure your work is reviewed and refined. 79 | 80 | **Instructions**: 81 | 1. **Call 'actor_think' for all actions**: 82 | - Planning, requirement capture, task breakdown, or coding steps. 83 | - Use the 'projectContext' property to specify the full path to the currently open directory. 84 | 2. **Always include at least one semantic tag** (e.g., 'requirement', 'task', 'file-modification', 'task-complete') to enable searchability and trigger appropriate reviews. 85 | 3. **Iterative Workflow**: 86 | - File modifications or task completions automatically trigger critic reviews. 87 | - Use the critic's feedback (in 'criticNode') to refine your next thought. 88 | 4. **Tags and artifacts are critical for tracking decisions and avoiding duplicate work**. 89 | 90 | **Example Workflow**: 91 | - Step 1: Call 'actor_think' with thought: "Create main.ts with initial setup", projectContext: "/path/to/project", artifacts: ['src/main.ts'], tags: ['file-modification']. 92 | - Response: Includes feedback from the critic 93 | - Step 2: Make any necessary changes and call 'actor_think' again with the updated thought. 94 | - Repeat until the all work is completed. 95 | 96 | **Note**: Do not call 'critic_review' directly unless debugging; 'actor_think' manages reviews automatically. 97 | `; 98 | 99 | /** 100 | * actor_think - Add a new thought node to the knowledge graph. 101 | */ 102 | server.tool('actor_think', ACTOR_THINK_DESCRIPTION, ActorThinkSchema, async (args) => { 103 | const { logger, engine, runOnce } = await getDeps(); 104 | const projectName = await loadProjectOrThrow({ logger, args, onProjectLoad: runOnce }); 105 | 106 | // Auto-generate comprehensive git diff 107 | const diff = await getGitDiff(logger); 108 | 109 | const node = await engine.actorThink({ 110 | ...args, 111 | project: projectName, 112 | diff, 113 | }); 114 | return { 115 | content: [ 116 | { 117 | type: 'text', 118 | text: JSON.stringify(node, null, 2), 119 | }, 120 | ], 121 | }; 122 | }); 123 | 124 | /** 125 | * critic_review – manually evaluates an actor node. 126 | */ 127 | server.tool( 128 | 'critic_review', 129 | 'Call this tool when you want explicit feedback on your thought, idea or final implementation of a task.', 130 | { 131 | actorNodeId: z.string().describe('ID of the actor node to critique.'), 132 | projectContext: z.string().describe('Full path to the project directory.'), 133 | }, 134 | async (a) => { 135 | const { logger, engine, runOnce } = await getDeps(); 136 | const projectName = await loadProjectOrThrow({ logger, args: a, onProjectLoad: runOnce }); 137 | return { 138 | content: [ 139 | { 140 | type: 'text', 141 | text: JSON.stringify( 142 | await engine.criticReview({ 143 | actorNodeId: a.actorNodeId, 144 | projectContext: a.projectContext, 145 | project: projectName, 146 | }), 147 | null, 148 | 2, 149 | ), 150 | }, 151 | ], 152 | }; 153 | }, 154 | ); 155 | 156 | server.tool( 157 | 'get_node', 158 | 'Get a specific node by ID', 159 | { 160 | id: z.string().describe('ID of the node to retrieve.'), 161 | }, 162 | async (a) => { 163 | const { kg } = await getDeps(); 164 | const node = await kg.getNode(a.id); 165 | return { 166 | content: [ 167 | { 168 | type: 'text', 169 | text: JSON.stringify(node, null, 2), 170 | }, 171 | ], 172 | }; 173 | }, 174 | ); 175 | 176 | server.tool( 177 | 'resume', 178 | 'Pick up where you left off by fetching the most recent nodes from the knowledge graph for this project. Use limit to control the number of nodes returned. Increase it if you need more context.', 179 | { 180 | projectContext: z.string().describe('Full path to the project directory.'), 181 | limit: z 182 | .number() 183 | .optional() 184 | .describe('Limit the number of nodes returned. Increase it if you need more context.'), 185 | includeDiffs: z 186 | .enum(['all', 'latest', 'none']) 187 | .optional() 188 | .default('latest') 189 | .describe('Control diff inclusion: "all" includes all diffs, "latest" includes only the most recent diff, "none" excludes all diffs. Defaults to "latest" to avoid context overflow.'), 190 | }, 191 | async (a) => { 192 | const { logger, kg, runOnce } = await getDeps(); 193 | const projectName = await loadProjectOrThrow({ logger, args: a, onProjectLoad: runOnce }); 194 | const text = await kg.resume({ 195 | project: projectName, 196 | limit: a.limit, 197 | includeDiffs: a.includeDiffs || 'latest', 198 | }); 199 | return { 200 | content: [{ type: 'text', text: JSON.stringify(text, null, 2) }], 201 | }; 202 | }, 203 | ); 204 | 205 | /** export – dump the current graph */ 206 | server.tool( 207 | 'export', 208 | 'dump the current knowledge graph, with optional limit', 209 | { 210 | limit: z.number().optional().describe('Limit the number of nodes returned.'), 211 | projectContext: z.string().describe('Full path to the project directory.'), 212 | }, 213 | async (a) => { 214 | const { logger, kg, runOnce } = await getDeps(); 215 | const projectName = await loadProjectOrThrow({ logger, args: a, onProjectLoad: runOnce }); 216 | const nodes = await kg.export({ project: projectName, limit: a.limit }); 217 | return { 218 | content: [ 219 | { 220 | type: 'text', 221 | text: JSON.stringify(nodes, null, 2), 222 | }, 223 | ], 224 | }; 225 | }, 226 | ); 227 | 228 | /** list_projects – list all available knowledge graph projects */ 229 | server.tool( 230 | 'list_projects', 231 | { 232 | projectContext: z 233 | .string() 234 | .optional() 235 | .describe( 236 | 'Optional full path to the project directory. If provided, the project name will be extracted and highlighted as current.', 237 | ), 238 | }, 239 | async (a) => { 240 | const { logger, kg } = await getDeps(); 241 | let activeProject: string | null = null; 242 | if (a.projectContext) { 243 | const projectName = extractProjectName(a.projectContext); 244 | if (!projectName) { 245 | throw new Error('Invalid projectContext'); 246 | } 247 | activeProject = projectName; 248 | } 249 | const projects = await kg.listProjects(); 250 | 251 | logger.info( 252 | `[list_projects] Current project: ${activeProject}, Available projects: ${projects.join(', ')}`, 253 | ); 254 | 255 | return { 256 | content: [ 257 | { 258 | type: 'text', 259 | text: JSON.stringify( 260 | { 261 | activeProject, 262 | projects, 263 | }, 264 | null, 265 | 2, 266 | ), 267 | }, 268 | ], 269 | }; 270 | }, 271 | ); 272 | 273 | /** delete_thoughts – safely soft-delete one or more knowledge graph nodes */ 274 | server.tool( 275 | 'delete_thoughts', 276 | 'Safely soft-delete one or more knowledge graph nodes within a project. Creates backup, checks dependencies, and rebuilds clean graph.', 277 | { 278 | nodeIds: z 279 | .array(z.string()) 280 | .min(1) 281 | .describe('Array of node IDs to delete. Must contain at least one ID.'), 282 | projectContext: z.string().describe('Full path to the project directory.'), 283 | reason: z 284 | .string() 285 | .optional() 286 | .describe('Optional reason for deletion (e.g., "accidental entry", "experimental spike").'), 287 | checkDependents: z 288 | .boolean() 289 | .default(true) 290 | .describe('Check for dependent nodes before deletion. Defaults to true.'), 291 | confirm: z 292 | .boolean() 293 | .default(false) 294 | .describe('Set to true to proceed with deletion after reviewing dependencies.'), 295 | }, 296 | async (a) => { 297 | const { logger, kg, runOnce } = await getDeps(); 298 | const projectName = await loadProjectOrThrow({ logger, args: a, onProjectLoad: runOnce }); 299 | 300 | // Check if nodes exist 301 | const nodeChecks = await Promise.all( 302 | a.nodeIds.map(async (id) => { 303 | const node = await kg.getNode(id); 304 | return { id, exists: !!node, node }; 305 | }), 306 | ); 307 | 308 | const nonExistentNodes = nodeChecks.filter((check) => !check.exists).map((check) => check.id); 309 | if (nonExistentNodes.length > 0) { 310 | throw new Error(`Nodes not found: ${nonExistentNodes.join(', ')}`); 311 | } 312 | 313 | // Filter nodes by project 314 | const projectNodes = nodeChecks.filter((check) => check.node?.project === projectName); 315 | const wrongProjectNodes = nodeChecks 316 | .filter((check) => check.node?.project !== projectName) 317 | .map((check) => check.id); 318 | 319 | if (wrongProjectNodes.length > 0) { 320 | throw new Error(`Nodes not in project ${projectName}: ${wrongProjectNodes.join(', ')}`); 321 | } 322 | 323 | if (a.checkDependents && !a.confirm) { 324 | // Check for dependent nodes 325 | const dependentsMap = await kg.findDependentNodes(a.nodeIds, projectName); 326 | const affectedSummaries = await kg.findAffectedSummaryNodes(a.nodeIds, projectName); 327 | 328 | const hasDependents = Array.from(dependentsMap.values()).some((deps) => deps.length > 0); 329 | const hasSummaries = affectedSummaries.length > 0; 330 | 331 | if (hasDependents || hasSummaries) { 332 | return { 333 | content: [ 334 | { 335 | type: 'text', 336 | text: JSON.stringify( 337 | { 338 | action: 'confirmation_required', 339 | nodesToDelete: projectNodes.map((check) => ({ 340 | id: check.id, 341 | thought: check.node?.thought, 342 | role: check.node?.role, 343 | tags: check.node?.tags, 344 | })), 345 | dependentNodes: Object.fromEntries(dependentsMap), 346 | affectedSummaries: affectedSummaries.map((node) => ({ 347 | id: node.id, 348 | thought: node.thought, 349 | summarizedSegment: node.summarizedSegment, 350 | })), 351 | message: 352 | 'These nodes have dependencies or are referenced in summaries. Set confirm=true to proceed with deletion.', 353 | }, 354 | null, 355 | 2, 356 | ), 357 | }, 358 | ], 359 | }; 360 | } 361 | } 362 | 363 | // Proceed with deletion 364 | const result = await kg.softDeleteNodes(a.nodeIds, projectName, a.reason, 'mcp-tool'); 365 | 366 | logger.info( 367 | `[delete_thoughts] Successfully deleted ${result.deletedNodes.length} nodes from project ${projectName}`, 368 | ); 369 | 370 | return { 371 | content: [ 372 | { 373 | type: 'text', 374 | text: JSON.stringify( 375 | { 376 | action: 'deletion_completed', 377 | deletedNodes: result.deletedNodes.map((node) => ({ 378 | id: node.id, 379 | thought: node.thought, 380 | role: node.role, 381 | deletedAt: node.deletedAt, 382 | deletedReason: node.deletedReason, 383 | })), 384 | backupPath: result.backupPath, 385 | affectedSummaries: result.affectedSummaries.map((node) => ({ 386 | id: node.id, 387 | thought: node.thought, 388 | summarizedSegment: node.summarizedSegment, 389 | })), 390 | message: `Successfully deleted ${result.deletedNodes.length} nodes. Backup created at ${result.backupPath}`, 391 | }, 392 | null, 393 | 2, 394 | ), 395 | }, 396 | ], 397 | }; 398 | }, 399 | ); 400 | }; 401 | -------------------------------------------------------------------------------- /src/utils/fun.ts: -------------------------------------------------------------------------------- 1 | //TODO: add color variations and such 2 | export const createCodeLoopsAscii = () => { 3 | return ` 4 | =========================================================================== 5 | 6 | ██████╗ ██████╗ ██████╗ ███████╗██╗ ██████╗ ██████╗ ██████╗ ███████╗ 7 | ██╔════╝██╔═══██╗██╔══██╗██╔════╝██║ ██╔═══██╗██╔═══██╗██╔══██╗██╔════╝ 8 | ██║ ██║ ██║██║ ██║█████╗ ██║ ██║ ██║██║ ██║██████╔╝███████╗ 9 | ██║ ██║ ██║██║ ██║██╔══╝ ██║ ██║ ██║██║ ██║██╔═══╝ ╚════██║ 10 | ╚██████╗╚██████╔╝██████╔╝███████╗███████╗╚██████╔╝╚██████╔╝██║ ███████║ 11 | ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝ 12 | 13 | =========================================================================== 14 | `; 15 | }; 16 | -------------------------------------------------------------------------------- /src/utils/git.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { getGitDiff } from './git.ts'; 3 | import { createLogger } from '../logger.ts'; 4 | import type { Result } from 'execa'; 5 | 6 | // Mock execa 7 | vi.mock('execa', () => ({ 8 | execa: vi.fn(), 9 | })); 10 | 11 | import { execa } from 'execa'; 12 | 13 | describe('getGitDiff', () => { 14 | const mockLogger = createLogger({ withFile: false, withDevStdout: false }); 15 | const mockExeca = vi.mocked(execa); 16 | 17 | beforeEach(() => { 18 | vi.clearAllMocks(); 19 | }); 20 | 21 | describe('happy path scenarios', () => { 22 | it('returns formatted diff with all sections when all git commands succeed', async () => { 23 | // Mock staged changes 24 | mockExeca.mockResolvedValueOnce({ 25 | stdout: 'diff --git a/file1.ts b/file1.ts\n+added line', 26 | stderr: '', 27 | exitCode: 0, 28 | } as Result); 29 | 30 | // Mock unstaged changes 31 | mockExeca.mockResolvedValueOnce({ 32 | stdout: 'diff --git a/file2.ts b/file2.ts\n-removed line', 33 | stderr: '', 34 | exitCode: 0, 35 | } as Result); 36 | 37 | // Mock untracked files 38 | mockExeca.mockResolvedValueOnce({ 39 | stdout: 'newfile.ts\nanother.ts', 40 | stderr: '', 41 | exitCode: 0, 42 | } as Result); 43 | 44 | const result = await getGitDiff(mockLogger); 45 | 46 | expect(result).toBe( 47 | '--- Staged Changes ---\ndiff --git a/file1.ts b/file1.ts\n+added line\n\n' + 48 | '--- Unstaged Changes ---\ndiff --git a/file2.ts b/file2.ts\n-removed line\n\n' + 49 | '--- Untracked Files ---\nnewfile.ts\nanother.ts' 50 | ); 51 | 52 | expect(mockExeca).toHaveBeenCalledTimes(3); 53 | expect(mockExeca).toHaveBeenNthCalledWith(1, 'git', ['diff', '--cached'], { reject: false }); 54 | expect(mockExeca).toHaveBeenNthCalledWith(2, 'git', ['diff'], { reject: false }); 55 | expect(mockExeca).toHaveBeenNthCalledWith(3, 'git', ['ls-files', '--others', '--exclude-standard'], { reject: false }); 56 | }); 57 | 58 | it('returns only staged changes when only staged files exist', async () => { 59 | // Mock staged changes 60 | mockExeca.mockResolvedValueOnce({ 61 | stdout: 'diff --git a/staged.ts b/staged.ts\n+staged change', 62 | stderr: '', 63 | exitCode: 0, 64 | } as Result); 65 | 66 | // Mock empty unstaged changes 67 | mockExeca.mockResolvedValueOnce({ 68 | stdout: '', 69 | stderr: '', 70 | exitCode: 0, 71 | } as Result); 72 | 73 | // Mock empty untracked files 74 | mockExeca.mockResolvedValueOnce({ 75 | stdout: '', 76 | stderr: '', 77 | exitCode: 0, 78 | } as Result); 79 | 80 | const result = await getGitDiff(mockLogger); 81 | 82 | expect(result).toBe('--- Staged Changes ---\ndiff --git a/staged.ts b/staged.ts\n+staged change'); 83 | }); 84 | 85 | it('returns only unstaged changes when only unstaged files exist', async () => { 86 | // Mock empty staged changes 87 | mockExeca.mockResolvedValueOnce({ 88 | stdout: '', 89 | stderr: '', 90 | exitCode: 0, 91 | } as Result); 92 | 93 | // Mock unstaged changes 94 | mockExeca.mockResolvedValueOnce({ 95 | stdout: 'diff --git a/unstaged.ts b/unstaged.ts\n-unstaged change', 96 | stderr: '', 97 | exitCode: 0, 98 | } as Result); 99 | 100 | // Mock empty untracked files 101 | mockExeca.mockResolvedValueOnce({ 102 | stdout: '', 103 | stderr: '', 104 | exitCode: 0, 105 | } as Result); 106 | 107 | const result = await getGitDiff(mockLogger); 108 | 109 | expect(result).toBe('--- Unstaged Changes ---\ndiff --git a/unstaged.ts b/unstaged.ts\n-unstaged change'); 110 | }); 111 | 112 | it('returns only untracked files when only untracked files exist', async () => { 113 | // Mock empty staged changes 114 | mockExeca.mockResolvedValueOnce({ 115 | stdout: '', 116 | stderr: '', 117 | exitCode: 0, 118 | } as Result); 119 | 120 | // Mock empty unstaged changes 121 | mockExeca.mockResolvedValueOnce({ 122 | stdout: '', 123 | stderr: '', 124 | exitCode: 0, 125 | } as Result); 126 | 127 | // Mock untracked files 128 | mockExeca.mockResolvedValueOnce({ 129 | stdout: 'untracked1.ts\nuntracked2.ts', 130 | stderr: '', 131 | exitCode: 0, 132 | } as Result); 133 | 134 | const result = await getGitDiff(mockLogger); 135 | 136 | expect(result).toBe('--- Untracked Files ---\nuntracked1.ts\nuntracked2.ts'); 137 | }); 138 | 139 | it('returns empty string when no changes exist', async () => { 140 | // Mock all empty results 141 | mockExeca.mockResolvedValue({ 142 | stdout: '', 143 | stderr: '', 144 | exitCode: 0, 145 | } as Result); 146 | 147 | const result = await getGitDiff(mockLogger); 148 | 149 | expect(result).toBe(''); 150 | expect(mockExeca).toHaveBeenCalledTimes(3); 151 | }); 152 | 153 | it('handles whitespace-only output correctly', async () => { 154 | // Mock results with whitespace 155 | mockExeca.mockResolvedValueOnce({ 156 | stdout: ' \n \t ', 157 | stderr: '', 158 | exitCode: 0, 159 | } as Result); 160 | 161 | mockExeca.mockResolvedValueOnce({ 162 | stdout: '\n\n', 163 | stderr: '', 164 | exitCode: 0, 165 | } as Result); 166 | 167 | mockExeca.mockResolvedValueOnce({ 168 | stdout: ' ', 169 | stderr: '', 170 | exitCode: 0, 171 | } as Result); 172 | 173 | const result = await getGitDiff(mockLogger); 174 | 175 | expect(result).toBe(''); 176 | }); 177 | }); 178 | }); -------------------------------------------------------------------------------- /src/utils/git.ts: -------------------------------------------------------------------------------- 1 | import { execa } from 'execa'; 2 | import { to } from 'await-to-js'; 3 | import { CodeLoopsLogger } from '../logger.ts'; 4 | 5 | /** 6 | * Git operations utility for CodeLoops 7 | */ 8 | 9 | /** 10 | * Generate a complete git diff for all changes in the repository. 11 | * This captures both staged and unstaged changes. 12 | * Gracefully fails and returns empty string if git is not available. 13 | */ 14 | export async function getGitDiff(logger: CodeLoopsLogger): Promise { 15 | const diffs: string[] = []; 16 | 17 | // Get staged changes 18 | const [stagedError, stagedResult] = await to( 19 | execa('git', ['diff', '--cached'], { 20 | reject: false, 21 | }), 22 | ); 23 | 24 | if (stagedError || stagedResult.exitCode !== 0) { 25 | logger.debug({ error: stagedError }, 'Failed to get staged git diff'); 26 | } else if (stagedResult.stdout.trim()) { 27 | diffs.push(`--- Staged Changes ---\n${stagedResult.stdout}`); 28 | } 29 | 30 | // Get unstaged changes 31 | const [unstagedError, unstagedResult] = await to( 32 | execa('git', ['diff'], { 33 | reject: false, 34 | }), 35 | ); 36 | 37 | if (unstagedError || unstagedResult.exitCode !== 0) { 38 | logger.debug({ error: unstagedError }, 'Failed to get unstaged git diff'); 39 | } else if (unstagedResult.stdout.trim()) { 40 | diffs.push(`--- Unstaged Changes ---\n${unstagedResult.stdout}`); 41 | } 42 | 43 | // Get untracked files 44 | const [untrackedError, untrackedResult] = await to( 45 | execa('git', ['ls-files', '--others', '--exclude-standard'], { 46 | reject: false, 47 | }), 48 | ); 49 | 50 | if (untrackedError || untrackedResult.exitCode !== 0) { 51 | logger.debug({ error: untrackedError }, 'Failed to get untracked files'); 52 | } else if (untrackedResult.stdout.trim()) { 53 | const untrackedFiles = untrackedResult.stdout.trim().split('\n'); 54 | diffs.push(`--- Untracked Files ---\n${untrackedFiles.join('\n')}`); 55 | } 56 | 57 | return diffs.join('\n\n'); 58 | } 59 | 60 | -------------------------------------------------------------------------------- /src/utils/project.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { extractProjectName } from './project.ts'; 3 | 4 | describe('extractProjectName', () => { 5 | it('returns project name for a valid path', () => { 6 | expect(extractProjectName('/Users/foo/bar/my_project')).toBe('my_project'); 7 | expect(extractProjectName('C:/dev/my-project')).toBe('my-project'); 8 | }); 9 | 10 | it('returns null for empty or invalid input', () => { 11 | expect(extractProjectName('')).toBeNull(); 12 | expect(extractProjectName(' ')).toBeNull(); 13 | expect(extractProjectName(undefined as unknown as string)).toBeNull(); 14 | expect(extractProjectName(null as unknown as string)).toBeNull(); 15 | }); 16 | 17 | it('replaces special characters with underscores', () => { 18 | expect(extractProjectName('/foo/bar/!@#my$%^proj&*()')).toBe('my_proj_'); 19 | }); 20 | 21 | it('returns null if only invalid characters', () => { 22 | expect(extractProjectName('/foo/bar/!@#$%^&*()')).toBeNull(); 23 | }); 24 | 25 | it('truncates long names to 50 chars', () => { 26 | const longName = 'a'.repeat(60); 27 | expect(extractProjectName(`/foo/bar/${longName}`)).toBe('a'.repeat(50)); 28 | }); 29 | 30 | it('handles trailing slashes and mixed separators', () => { 31 | expect(extractProjectName('/foo/bar/project/')).toBe('project'); 32 | expect(extractProjectName('foo\\bar\\proj')).toBe('proj'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/utils/project.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from 'pino'; 2 | import { getInstance as getLogger } from '../logger.ts'; 3 | 4 | const PROJECT_CONTEXT_CACHE = new Map(); 5 | 6 | /** 7 | * Extracts a valid project name from a project context (typically a file path). 8 | * 9 | * @param projectContext The project context, typically a file path 10 | * @returns A valid project name or null if the context is invalid 11 | */ 12 | export function extractProjectName( 13 | projectContext: string, 14 | { logger }: { logger: Logger } = { logger: getLogger() }, 15 | ): string | null { 16 | if (!projectContext || typeof projectContext !== 'string' || projectContext.trim() === '') { 17 | logger.info(`Invalid projectContext: ${projectContext}`); 18 | return null; 19 | } 20 | 21 | if (PROJECT_CONTEXT_CACHE.has(projectContext)) { 22 | return PROJECT_CONTEXT_CACHE.get(projectContext) ?? null; 23 | } 24 | 25 | // For Windows-style paths, we need to handle backslashes 26 | // Convert all backslashes to forward slashes for consistent handling 27 | const normalizedInput = projectContext.replace(/\\/g, '/'); 28 | 29 | // For paths with mixed separators, split by both types and get the last non-empty segment 30 | const segments = normalizedInput.split('/').filter(Boolean); 31 | const lastSegment = segments.length > 0 ? segments[segments.length - 1] : ''; 32 | 33 | if (!lastSegment) { 34 | logger.info(`Invalid projectContext (no valid segments): ${projectContext}`); 35 | return null; 36 | } 37 | 38 | // Check if the segment contains any valid characters (letters, numbers, hyphen, underscore) 39 | const hasValidChars = /[a-zA-Z0-9_-]/.test(lastSegment); 40 | if (!hasValidChars) { 41 | logger.info(`Invalid project name (no valid characters): ${lastSegment}`); 42 | return null; 43 | } 44 | 45 | // Replace special characters with underscores 46 | let cleanedProjectName = lastSegment.replace(/[^a-zA-Z0-9_-]/g, '_'); 47 | 48 | // Clean up multiple consecutive underscores but preserve trailing underscore 49 | const hasTrailingUnderscore = cleanedProjectName.endsWith('_'); 50 | cleanedProjectName = cleanedProjectName.replace(/_+/g, '_'); 51 | 52 | // Remove leading underscores but keep trailing if it was there 53 | cleanedProjectName = cleanedProjectName.replace(/^_+/, ''); 54 | if (hasTrailingUnderscore && !cleanedProjectName.endsWith('_')) { 55 | cleanedProjectName += '_'; 56 | } 57 | 58 | // Truncate to maximum length 59 | cleanedProjectName = cleanedProjectName.substring(0, 50); 60 | 61 | // Check if the name is empty or contains only invalid characters 62 | if (!cleanedProjectName) { 63 | logger.info(`Invalid project name (empty after cleaning): ${lastSegment}`); 64 | return null; 65 | } 66 | 67 | const validNameRegex = /^[a-zA-Z0-9_-]+$/; 68 | if (!validNameRegex.test(cleanedProjectName)) { 69 | logger.info(`Invalid project name: ${cleanedProjectName}`); 70 | return null; 71 | } 72 | 73 | PROJECT_CONTEXT_CACHE.set(projectContext, cleanedProjectName); 74 | return cleanedProjectName; 75 | } 76 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node22/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "target": "ESNext", 7 | "allowImportingTsExtensions": true, 8 | "allowSyntheticDefaultImports": true, 9 | "noEmit": true, 10 | "outDir": "./dist", 11 | "rootDir": "./src", 12 | "strict": true, 13 | "esModuleInterop": true, 14 | "skipLibCheck": true, 15 | "sourceMap": true, 16 | "declaration": false, 17 | "lib": ["ES2020"], 18 | "resolveJsonModule": true, 19 | "noEmitOnError": true, 20 | "forceConsistentCasingInFileNames": true 21 | }, 22 | "include": ["src/**/*"], 23 | "exclude": ["node_modules", "dist"] 24 | } 25 | --------------------------------------------------------------------------------