From f82413484a18f19c55f315a1bad7ca85f002bfec Mon Sep 17 00:00:00 2001 From: Rushikesh More Date: Sun, 8 Mar 2026 23:08:23 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20v0.5.0=20=E2=80=94=20context-safe?= =?UTF-8?q?=20responses,=20ranked=20search,=20agent=20onboarding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response size caps: all read tools now use size-adaptive limits based on project classification (micro/small/medium/large/extra-large). Responses stay under 10K chars on large repos. detail="brief"|"full" flag on 7 tools. Search rewrite: 3-tier ranked search (symbols→files→docs) with kind bonus, export bonus, and local variable demotion. Exported functions rank above local const assignments. Agent onboarding: generates .codecortex/AGENT.md with tool guide, appends pointer to CLAUDE.md/.cursorrules/.windsurfrules/copilot-instructions.md. New files: project-size.ts (classification + limits), truncate.ts (response helpers), agent-instructions.ts (onboarding), SECURITY.md, CI workflow. Init improvements: compact overview.md (directory summary, not raw file listing), step 7 agent instructions, MCP connection commands in output. 277 tests pass, tsc clean. Co-Authored-By: Claude Opus 4.6 --- .github/dependabot.yml | 22 +++ .github/workflows/ci.yml | 36 +++++ .github/workflows/codeql.yml | 31 ++++ .github/workflows/scorecard.yml | 35 +++++ CLAUDE.md | 7 +- README.md | 40 ++++- SECURITY.md | 62 ++++++++ package.json | 2 +- src/cli/commands/init.ts | 66 +++++--- src/core/agent-instructions.ts | 106 +++++++++++++ src/core/constitution.ts | 29 +++- src/core/manifest.ts | 7 +- src/core/module-gen.ts | 23 ++- src/core/project-size.ts | 160 ++++++++++++++++++++ src/core/search.ts | 208 ++++++++++++++++++++++++-- src/mcp/server.ts | 2 +- src/mcp/tools/read.ts | 167 ++++++++++++++++----- src/types/index.ts | 3 + src/utils/truncate.ts | 72 +++++++++ tests/core/agent-instructions.test.ts | 124 +++++++++++++++ tests/core/module-gen.test.ts | 99 ++++++++++++ tests/core/project-size.test.ts | 77 ++++++++++ tests/core/search.test.ts | 108 +++++++++++-- tests/fixtures/setup.ts | 17 ++- tests/mcp/read-tools.test.ts | 118 ++++++++++++++- tests/utils/truncate.test.ts | 93 ++++++++++++ 26 files changed, 1595 insertions(+), 119 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/scorecard.yml create mode 100644 SECURITY.md create mode 100644 src/core/agent-instructions.ts create mode 100644 src/core/project-size.ts create mode 100644 src/utils/truncate.ts create mode 100644 tests/core/agent-instructions.test.ts create mode 100644 tests/core/module-gen.test.ts create mode 100644 tests/core/project-size.test.ts create mode 100644 tests/utils/truncate.test.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..869c836 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 10 + labels: + - "dependencies" + commit-message: + prefix: "deps" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + labels: + - "ci" + commit-message: + prefix: "ci" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a442dc7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Type check + run: npx tsc --noEmit + + - name: Run tests + run: npm test + + - name: Security audit + run: npm audit --audit-level=high --omit=dev + continue-on-error: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..91f7fe3 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,31 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 6 * * 1' # Weekly on Monday at 6am UTC + +jobs: + analyze: + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..9e24f65 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,35 @@ +name: OpenSSF Scorecard + +on: + push: + branches: [main] + schedule: + - cron: '0 6 * * 1' # Weekly on Monday at 6am UTC + +permissions: read-all + +jobs: + analysis: + runs-on: ubuntu-latest + permissions: + security-events: write + id-token: write + contents: read + actions: read + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Run Scorecard + uses: ossf/scorecard-action@v2.4.0 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/CLAUDE.md b/CLAUDE.md index fa037ed..2329794 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Persistent, AI-powered codebase knowledge layer. Pre-digests codebases into stru - TypeScript, ESM (`"type": "module"`) - tree-sitter (native N-API) + 27 language grammar packages - @modelcontextprotocol/sdk - MCP server (stdio transport) -- commander - CLI (init, serve, update, status) +- commander - CLI (init, serve, update, status, symbols, search, modules, hotspots, hook, upgrade) - simple-git - git integration + temporal analysis - zod - schema validation for LLM analysis results - yaml - cortex.yaml manifest @@ -54,6 +54,7 @@ Read (10): get_project_overview, get_module_context, get_session_briefing, searc Write (5): analyze_module, save_module_analysis, record_decision, update_patterns, report_feedback All read tools include `_freshness` metadata (status, lastAnalyzed, filesChangedSince, changedFiles, message). +All read tools return context-safe responses (<10K chars) via truncation utilities in `src/utils/truncate.ts`. ## Pre-Publish Checklist Run ALL of these before `npm publish`. Do not skip any step. @@ -90,11 +91,11 @@ Run ALL of these before `npm publish`. Do not skip any step. src/ cli/ - commander CLI (init, serve, update, status) mcp/ - MCP server + tools - core/ - knowledge store (graph, modules, decisions, sessions, patterns, constitution, search) + core/ - knowledge store (graph, modules, decisions, sessions, patterns, constitution, search, agent-instructions, freshness) extraction/ - tree-sitter native N-API (parser, symbols, imports, calls) git/ - git diff, history, temporal analysis types/ - TypeScript types + Zod schemas - utils/ - file I/O, YAML, markdown helpers + utils/ - file I/O, YAML, markdown helpers, truncation ``` ## Temporal Analysis diff --git a/README.md b/README.md index a50c9bb..6323b94 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,14 @@ Persistent codebase knowledge layer for AI agents. Your AI shouldn't re-learn your codebase every session. -> **⚠️ If you're on v0.4.3 or earlier, update now:** `npm install -g codecortex-ai@latest` -> v0.4.4 adds freshness flags on all MCP responses and `get_edit_briefing` — a pre-edit risk briefing tool. +[![CI](https://github.com/rushikeshmore/CodeCortex/actions/workflows/ci.yml/badge.svg)](https://github.com/rushikeshmore/CodeCortex/actions/workflows/ci.yml) +[![npm version](https://img.shields.io/npm/v/codecortex-ai)](https://www.npmjs.com/package/codecortex-ai) +[![npm downloads](https://img.shields.io/npm/dw/codecortex-ai)](https://www.npmjs.com/package/codecortex-ai) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/rushikeshmore/CodeCortex/badge)](https://scorecard.dev/viewer/?uri=github.com/rushikeshmore/CodeCortex) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/rushikeshmore/CodeCortex/blob/main/LICENSE) + +> **⚠️ If you're on v0.4.x or earlier, update now:** `npm install -g codecortex-ai@latest` +> v0.5.0 adds context-safe response caps on all tools, ranked symbol search, agent auto-onboarding, and parameter consistency fixes. [Website](https://codecortex-ai.vercel.app) · [npm](https://www.npmjs.com/package/codecortex-ai) · [GitHub](https://github.com/rushikeshmore/CodeCortex) @@ -47,8 +53,26 @@ codecortex status ### Connect to Claude Code -Add to your MCP config: +**CLI (recommended):** +```bash +claude mcp add codecortex -- codecortex serve +``` + +**Or add to MCP config manually:** +```json +{ + "mcpServers": { + "codecortex": { + "command": "codecortex", + "args": ["serve"], + "cwd": "/path/to/your-project" + } + } +} +``` +### Connect to Cursor +Add to `.cursor/mcp.json`: ```json { "mcpServers": { @@ -104,12 +128,12 @@ Example from a real codebase: | Tool | Description | |------|-------------| -| `get_project_overview` | Constitution + overview + graph summary | -| `get_module_context` | Module doc by name, includes temporal signals | +| `get_project_overview` | Constitution + graph summary (context-safe, ~2K chars) | +| `get_module_context` | Module doc by name, includes temporal signals (capped at 8K) | | `get_session_briefing` | Changes since last session | -| `search_knowledge` | Keyword search across all knowledge | -| `get_decision_history` | Decision records filtered by topic | -| `get_dependency_graph` | Import/export graph, filterable | +| `search_knowledge` | Ranked search across symbols, file paths, and docs | +| `get_decision_history` | Decision records filtered by topic (capped at 10) | +| `get_dependency_graph` | Summary dashboard or scoped edges (capped at 50) | | `lookup_symbol` | Symbol by name/file/kind | | `get_change_coupling` | What files must I also edit if I touch X? | | `get_hotspots` | Files ranked by risk (churn x coupling) | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1aff991 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,62 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 0.5.x | Yes | +| 0.4.x | Security fixes only | +| < 0.4 | No | + +## Reporting a Vulnerability + +If you discover a security vulnerability in CodeCortex, please report it responsibly. + +**Do NOT open a public GitHub issue for security vulnerabilities.** + +### How to Report + +Email: **rushikeshmore271@gmail.com** + +Include: +- Description of the vulnerability +- Steps to reproduce +- Affected versions +- Impact assessment (what an attacker could do) + +### Response Timeline + +- **48 hours:** Acknowledgment of your report +- **7 days:** Assessment and fix plan communicated +- **30 days:** Fix released (or earlier for critical issues) + +### What to Expect + +1. We will acknowledge your report within 48 hours +2. We will investigate and determine the severity +3. We will develop and test a fix +4. We will release a patched version on npm +5. We will credit you in the release notes (unless you prefer anonymity) + +### Scope + +CodeCortex is a CLI tool and MCP server that reads and analyzes codebases. Security issues we care about include: + +- **Command injection** via file paths or user input +- **Path traversal** outside the intended project directory +- **Information disclosure** of sensitive file contents +- **Dependency vulnerabilities** in production dependencies +- **MCP protocol abuse** that could affect connected AI agents + +### Past Security Fixes + +We take security seriously and fix issues promptly: + +- **v0.4.4** (2026-03-08): Fixed command injection vulnerability in file discovery — replaced `execSync('cat')` with `readFileSync` to prevent shell metacharacter exploitation ([commit 128bba5](https://github.com/rushikeshmore/CodeCortex/commit/128bba5)) + +## Security Best Practices for Users + +- Always run CodeCortex on **trusted codebases** — it reads and analyzes file contents +- Keep CodeCortex updated: `npm install -g codecortex-ai@latest` +- Use Node.js 20-22 (the supported and tested versions) +- Review `.codecortex/` output before committing to your repo if your codebase contains sensitive patterns diff --git a/package.json b/package.json index 68c6f8f..f0c7be1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codecortex-ai", - "version": "0.4.4", + "version": "0.5.0", "description": "Permanent codebase memory for AI agents — extracts symbols, deps, and patterns, serves via MCP", "type": "module", "bin": { diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 4880eae..3539578 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -13,6 +13,8 @@ import { extractCalls } from '../../extraction/calls.js' import { writeFile, writeJsonStream, ensureDir, cortexPath } from '../../utils/files.js' import { readFile } from 'node:fs/promises' import { generateStructuralModuleDocs } from '../../core/module-gen.js' +import { generateAgentInstructions } from '../../core/agent-instructions.js' +import { createDecision, writeDecision, listDecisions } from '../../core/decisions.js' import type { SymbolRecord, ImportEdge, CallEdge, SymbolIndex, ProjectInfo } from '../../types/index.js' export async function initCommand(opts: { root: string; days: string }): Promise { @@ -23,7 +25,7 @@ export async function initCommand(opts: { root: string; days: string }): Promise console.log('') // Step 1: Discover project - console.log('Step 1/6: Discovering project structure...') + console.log('Step 1/7: Discovering project structure...') const project = await discoverProject(root) console.log(` Found ${project.files.length} files in ${project.modules.length} modules`) console.log(` Languages: ${project.languages.join(', ')}`) @@ -31,7 +33,7 @@ export async function initCommand(opts: { root: string; days: string }): Promise console.log('') // Step 2: Initialize tree-sitter and extract symbols - console.log('Step 2/6: Extracting symbols with tree-sitter...') + console.log('Step 2/7: Extracting symbols with tree-sitter...') await initParser() const allSymbols: SymbolRecord[] = [] @@ -75,7 +77,7 @@ export async function initCommand(opts: { root: string; days: string }): Promise console.log('') // Step 3: Build dependency graph - console.log('Step 3/6: Building dependency graph...') + console.log('Step 3/7: Building dependency graph...') const moduleNodes = buildModuleNodes(project.modules, project.files, allSymbols) // Detect external dependencies @@ -106,7 +108,7 @@ export async function initCommand(opts: { root: string; days: string }): Promise console.log('') // Step 4: Temporal analysis (git history) - console.log('Step 4/6: Analyzing git history...') + console.log('Step 4/7: Analyzing git history...') let temporalData = null const hasGit = await isGitRepo(root) if (hasGit) { @@ -121,7 +123,7 @@ export async function initCommand(opts: { root: string; days: string }): Promise console.log('') // Step 5: Write knowledge files - console.log('Step 5/6: Writing knowledge files...') + console.log('Step 5/7: Writing knowledge files...') await ensureDir(cortexPath(root)) await ensureDir(cortexPath(root, 'modules')) await ensureDir(cortexPath(root, 'decisions')) @@ -143,7 +145,8 @@ export async function initCommand(opts: { root: string; days: string }): Promise await writeFile(cortexPath(root, 'temporal.json'), JSON.stringify(temporalData, null, 2)) } - // Write overview.md + // Write overview.md — compact summary only (no raw file listing) + // The constitution + graph.json already contain all the detail an agent needs. const overview = generateOverview(project) await writeFile(cortexPath(root, 'overview.md'), overview) @@ -172,7 +175,7 @@ export async function initCommand(opts: { root: string; days: string }): Promise console.log('') // Step 6: Generate constitution - console.log('Step 6/6: Generating constitution...') + console.log('Step 6/7: Generating constitution...') await generateConstitution(root, { modules: moduleNodes, entryPoints: project.entryPoints, @@ -182,6 +185,26 @@ export async function initCommand(opts: { root: string; days: string }): Promise console.log(' Written: constitution.md') console.log('') + // Step 7: Agent onboarding + console.log('Step 7/7: Generating agent instructions...') + const updatedFiles = await generateAgentInstructions(root) + + // Seed a starter decision (skip if decisions already exist) + const existingDecisions = await listDecisions(root) + if (existingDecisions.length === 0) { + const seedDecision = createDecision({ + title: 'Initialized CodeCortex for codebase knowledge', + context: 'AI agents need persistent knowledge to avoid re-learning the codebase each session.', + decision: 'Using CodeCortex to pre-analyze symbols, dependencies, coupling, and patterns.', + alternatives: ['Manual CLAUDE.md only', 'No codebase context for agents'], + consequences: ['AI agents start with knowledge', '.codecortex/ added to repo', 'Knowledge needs periodic update via codecortex update'], + }) + await writeDecision(root, seedDecision) + } + + console.log(` Written: ${updatedFiles.join(', ')}`) + console.log('') + // Summary const head = await getHeadCommit(root) console.log('─'.repeat(50)) @@ -203,7 +226,11 @@ export async function initCommand(opts: { root: string; days: string }): Promise } console.log('') console.log(`Knowledge stored in: ${cortexPath(root)}`) - console.log('Run `codecortex serve` to start the MCP server.') + console.log('') + console.log('Connect your AI agent:') + console.log(' Claude Code: claude mcp add codecortex -- codecortex serve') + console.log(' Claude Desktop: Add to claude_desktop_config.json (see README)') + console.log(' Cursor: Add to .cursor/mcp.json (see README)') } function generateOverview(project: ProjectInfo): string { @@ -220,26 +247,23 @@ function generateOverview(project: ProjectInfo): string { `## Modules`, ...project.modules.map(m => `- **${m}**`), '', - `## File Map`, + `## Directory Summary`, ] - // Group files by directory - const dirs = new Map() + // Group files by top-level directory with counts (not raw file listings) + const topDirs = new Map() for (const file of project.files) { const parts = file.path.split('/') - const dir = parts.length > 1 ? parts.slice(0, -1).join('/') : '.' - const existing = dirs.get(dir) || [] - const fileName = parts[parts.length - 1] - if (fileName) existing.push(fileName) - dirs.set(dir, existing) + const topDir = parts.length > 1 ? parts[0]! : '.' + topDirs.set(topDir, (topDirs.get(topDir) || 0) + 1) } - for (const [dir, files] of [...dirs.entries()].sort()) { - lines.push(`\n### ${dir}/`) - for (const file of files.sort()) { - lines.push(`- ${file}`) - } + for (const [dir, count] of [...topDirs.entries()].sort((a, b) => b[1] - a[1])) { + lines.push(`- **${dir}/** — ${count} files`) } + lines.push('') + lines.push('> For detailed file lists, use `search_knowledge` or `get_dependency_graph` MCP tools.') + return lines.join('\n') + '\n' } diff --git a/src/core/agent-instructions.ts b/src/core/agent-instructions.ts new file mode 100644 index 0000000..8a571f9 --- /dev/null +++ b/src/core/agent-instructions.ts @@ -0,0 +1,106 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { readFile, writeFile as writeFileFs } from 'node:fs/promises' +import { writeFile, ensureDir, cortexPath } from '../utils/files.js' + +const CODECORTEX_SECTION_MARKER = '## CodeCortex' + +export const AGENT_INSTRUCTIONS = `# CodeCortex — Codebase Knowledge Tools + +This project uses CodeCortex for persistent codebase knowledge. These MCP tools give you pre-analyzed context — prefer them over raw Read/Grep/Glob. + +## Orientation (start here) +- \`get_project_overview\` — architecture, modules, risk map. Call this first. +- \`search_knowledge\` — search functions, types, files, and docs by keyword. Faster than grep for concepts. + +## Before Editing (ALWAYS call these) +- \`get_edit_briefing\` — co-change risks, hidden dependencies, bug history for files you plan to edit. +- \`get_change_coupling\` — files that historically change together. Missing one causes bugs. +- \`lookup_symbol\` — find where a function/class/type is defined. + +## Deep Dive +- \`get_module_context\` — purpose, API, gotchas, and dependencies of a specific module. +- \`get_dependency_graph\` — import/export graph filtered by file or module. +- \`get_hotspots\` — files ranked by risk (churn + coupling + bugs). +- \`get_decision_history\` — architectural decisions and their rationale. +- \`get_session_briefing\` — what changed since the last session. + +## Response Detail Control +These tools accept a \`detail\` parameter (\`"brief"\` or \`"full"\`): \`get_module_context\`, \`get_dependency_graph\`, \`get_decision_history\`, \`lookup_symbol\`, \`get_change_coupling\`, \`search_knowledge\`, \`get_edit_briefing\`. +- **brief** (default) — size-adaptive caps. Small projects show more, large projects truncate aggressively. Best for exploration. +- **full** — returns complete data up to hard safety limits. Use when you need exhaustive info for a specific analysis. +Only use \`detail: "full"\` when brief results are insufficient — it increases response size significantly on large codebases. + +## Building Knowledge (call as you work) +- \`record_decision\` — when you make a non-obvious technical choice, record WHY. +- \`update_patterns\` — when you discover a coding convention, document it. +- \`analyze_module\` + \`save_module_analysis\` — deep-analyze a module's purpose and API. +- \`report_feedback\` — if any CodeCortex knowledge is wrong or outdated, report it. +` + +const CLAUDEMD_POINTER = ` +${CODECORTEX_SECTION_MARKER} +This project uses CodeCortex for codebase knowledge. See \`.codecortex/AGENT.md\` for available MCP tools and when to use them. +` + +// All known agent instruction files across AI coding tools +const AGENT_CONFIG_FILES = [ + 'CLAUDE.md', // Claude Code, Claude Desktop + '.cursorrules', // Cursor + '.windsurfrules', // Windsurf + 'AGENTS.md', // Generic / multi-agent convention + '.github/copilot-instructions.md', // GitHub Copilot +] + +export async function generateAgentInstructions(projectRoot: string): Promise { + // 1. Write .codecortex/AGENT.md (canonical source of truth) + await ensureDir(cortexPath(projectRoot)) + await writeFile(cortexPath(projectRoot, 'AGENT.md'), AGENT_INSTRUCTIONS) + + // 2. Append pointer to every agent config file that exists + // If NONE exist, create CLAUDE.md as default + const updated: string[] = ['AGENT.md'] + let foundAny = false + + for (const file of AGENT_CONFIG_FILES) { + const filePath = join(projectRoot, file) + if (existsSync(filePath)) { + foundAny = true + const wasUpdated = await appendPointerToFile(filePath) + if (wasUpdated) updated.push(file) + } + } + + // If no agent config files exist at all, create CLAUDE.md as default + if (!foundAny) { + await appendPointerToFile(join(projectRoot, 'CLAUDE.md')) + updated.push('CLAUDE.md') + } + + return updated +} + +async function appendPointerToFile(filePath: string): Promise { + // Ensure parent directory exists (for .github/copilot-instructions.md) + const dir = join(filePath, '..') + if (!existsSync(dir)) { + const { mkdir } = await import('node:fs/promises') + await mkdir(dir, { recursive: true }) + } + + if (existsSync(filePath)) { + const content = await readFileFs(filePath, 'utf-8') + // Don't duplicate — check if CodeCortex section already exists + if (content.includes(CODECORTEX_SECTION_MARKER)) return false + await writeFileFs(filePath, content + CLAUDEMD_POINTER, 'utf-8') + return true + } else { + // Create new file with just the pointer + await writeFileFs(filePath, CLAUDEMD_POINTER.trimStart(), 'utf-8') + return true + } +} + +async function readFileFs(path: string, encoding: BufferEncoding): Promise { + return readFile(path, encoding) +} diff --git a/src/core/constitution.ts b/src/core/constitution.ts index 265bb38..0f774ee 100644 --- a/src/core/constitution.ts +++ b/src/core/constitution.ts @@ -3,6 +3,7 @@ import { readFile, writeFile, cortexPath } from '../utils/files.js' import { readManifest } from './manifest.js' import { listModuleDocs } from './modules.js' import { listDecisions } from './decisions.js' +import { classifyProject, getSizeLimits } from './project-size.js' export interface ConstitutionData { modules?: ModuleNode[] @@ -41,6 +42,12 @@ export async function generateConstitution(projectRoot: string, data?: Constitut } } + // Determine size-based limits for constitution content + const size = manifest + ? classifyProject(manifest.totalFiles, manifest.totalSymbols, manifest.totalModules) + : (graphModules ? classifyProject(graphModules.length * 10, graphModules.reduce((s, m) => s + m.symbols, 0), graphModules.length) : 'medium' as const) + const limits = getSizeLimits(size) + const lines: string[] = [ `# ${manifest?.project || 'Project'} — Constitution`, '', @@ -70,17 +77,25 @@ export async function generateConstitution(projectRoot: string, data?: Constitut lines.push(`**Entry points:** ${entryPoints.map(e => `\`${e}\``).join(', ')}`, '') } - lines.push(`**Modules:**`) - for (const mod of graphModules) { + const modCap = limits.constitutionModules + lines.push(`**Modules (${graphModules.length}):**`) + const sorted = [...graphModules].sort((a, b) => b.lines - a.lines) + const shown = sorted.slice(0, modCap) + for (const mod of shown) { lines.push(`- **${mod.name}** (${mod.files.length} files, ${mod.lines} lines) — ${mod.language}`) } + if (graphModules.length > modCap) { + lines.push(`- ...and ${graphModules.length - modCap} more modules`) + } lines.push('') // Key dependencies if (externalDeps) { const extDeps = Object.keys(externalDeps) if (extDeps.length > 0) { - lines.push(`**External dependencies:** ${extDeps.map(d => `\`${d}\``).join(', ')}`, '') + const depCap = limits.constitutionDeps + const shownDeps = extDeps.slice(0, depCap) + lines.push(`**External dependencies (${extDeps.length}):** ${shownDeps.map(d => `\`${d}\``).join(', ')}${extDeps.length > depCap ? `, ...and ${extDeps.length - depCap} more` : ''}`, '') } } } @@ -90,7 +105,7 @@ export async function generateConstitution(projectRoot: string, data?: Constitut lines.push(`## Risk Map`, '') // Top hotspots - const topHotspots = temporal.hotspots.slice(0, 5) + const topHotspots = temporal.hotspots.slice(0, limits.constitutionHotspots) if (topHotspots.length > 0) { lines.push(`**Hottest files (most changes):**`) for (const h of topHotspots) { @@ -103,7 +118,7 @@ export async function generateConstitution(projectRoot: string, data?: Constitut const hiddenCouplings = temporal.coupling.filter(c => !c.hasImport && c.strength >= 0.5) if (hiddenCouplings.length > 0) { lines.push(`**Hidden dependencies (co-change but no import):**`) - for (const c of hiddenCouplings.slice(0, 5)) { + for (const c of hiddenCouplings.slice(0, limits.constitutionCouplings)) { lines.push(`- \`${c.fileA}\` ↔ \`${c.fileB}\` — ${c.cochanges} co-changes (${Math.round(c.strength * 100)}%)`) } lines.push('') @@ -113,9 +128,9 @@ export async function generateConstitution(projectRoot: string, data?: Constitut const buggy = temporal.bugHistory.filter(b => b.fixCommits >= 2) if (buggy.length > 0) { lines.push(`**Bug-prone files:**`) - for (const b of buggy.slice(0, 5)) { + for (const b of buggy.slice(0, limits.constitutionBugs)) { lines.push(`- \`${b.file}\` — ${b.fixCommits} fix commits`) - for (const lesson of b.lessons.slice(0, 3)) { + for (const lesson of b.lessons.slice(0, limits.constitutionLessons)) { lines.push(` - ${lesson}`) } } diff --git a/src/core/manifest.ts b/src/core/manifest.ts index 0e6afe7..1bd09e8 100644 --- a/src/core/manifest.ts +++ b/src/core/manifest.ts @@ -1,6 +1,7 @@ import type { CortexManifest } from '../types/index.js' import { readFile, writeFile, cortexPath } from '../utils/files.js' import { parseYaml, stringifyYaml } from '../utils/yaml.js' +import { classifyProject } from './project-size.js' export async function readManifest(projectRoot: string): Promise { const content = await readFile(cortexPath(projectRoot, 'cortex.yaml')) @@ -32,6 +33,7 @@ export function createManifest(opts: { totalFiles: opts.totalFiles, totalSymbols: opts.totalSymbols, totalModules: opts.totalModules, + projectSize: classifyProject(opts.totalFiles, opts.totalSymbols, opts.totalModules), tiers: { hot: ['cortex.yaml', 'constitution.md', 'overview.md', 'graph.json', 'symbols.json', 'temporal.json'], warm: ['modules/'], @@ -47,10 +49,11 @@ export async function updateManifest( const manifest = await readManifest(projectRoot) if (!manifest) return null + const merged = { ...manifest, ...updates } const updated: CortexManifest = { - ...manifest, - ...updates, + ...merged, lastUpdated: new Date().toISOString(), + projectSize: classifyProject(merged.totalFiles, merged.totalSymbols, merged.totalModules), } await writeManifest(projectRoot, updated) diff --git a/src/core/module-gen.ts b/src/core/module-gen.ts index 1844d7b..9e7442d 100644 --- a/src/core/module-gen.ts +++ b/src/core/module-gen.ts @@ -2,6 +2,8 @@ import { existsSync } from 'node:fs' import { cortexPath } from '../utils/files.js' import { writeModuleDoc } from './modules.js' import { getModuleDependencies } from './graph.js' +import { summarizeFileList } from '../utils/truncate.js' +import { classifyProject, getSizeLimits } from './project-size.js' import type { DependencyGraph, SymbolRecord, TemporalData, ModuleAnalysis } from '../types/index.js' export interface StructuralModuleData { @@ -14,6 +16,9 @@ export async function generateStructuralModuleDocs( projectRoot: string, data: StructuralModuleData, ): Promise { + const totalFiles = data.graph.modules.reduce((s, m) => s + m.files.length, 0) + const totalSymbols = data.symbols.length + const limits = getSizeLimits(classifyProject(totalFiles, totalSymbols, data.graph.modules.length)) let generated = 0 for (const mod of data.graph.modules) { @@ -81,11 +86,25 @@ export async function generateStructuralModuleDocs( } } + // Group files by type instead of raw list dump + const fileSummary = summarizeFileList(mod.files) + const dataFlow = Object.entries(fileSummary.byType) + .map(([type, { count, sample }]) => { + const sampleStr = sample.slice(0, limits.moduleFileSampleCap).join(', ') + return `${type}: ${count} files (${sampleStr}${count > limits.moduleFileSampleCap ? ', ...' : ''})` + }) + .join('. ') || `${mod.files.length} files` + + const symCap = limits.moduleExportedSymbolCap + const cappedExported = exported.length > symCap + ? [...exported.slice(0, symCap), `...and ${exported.length - symCap} more`] + : exported + const analysis: ModuleAnalysis = { name: mod.name, purpose: `${mod.files.length} files, ${mod.lines} lines (${mod.language}). Auto-generated from code structure — use \`analyze_module\` MCP tool for semantic analysis.`, - dataFlow: `Files: ${mod.files.join(', ')}`, - publicApi: exported, + dataFlow, + publicApi: cappedExported, gotchas: [], dependencies: depLines, temporalSignals, diff --git a/src/core/project-size.ts b/src/core/project-size.ts new file mode 100644 index 0000000..27ee4be --- /dev/null +++ b/src/core/project-size.ts @@ -0,0 +1,160 @@ +/** + * Project size classification. + * + * Determines response truncation limits based on project scale. + * Small repos get full detail; large repos get intelligent summaries. + * + * Calibrated against real-world repos: + * micro: AgentKarma (23 files, 605 symbols) + * small: CodeCortex (57 files, 1.3K symbols), supabase TS (~100 files) + * medium: nestjs (~500 files, 60K LOC), storybook (~1K files) + * large: OpenClaw (6.4K files, 143K symbols), vscode (~6K files) + * extra-large: kibana (~20K files, 2.7M LOC), Linux kernel (93K files) + * + * Primary signal: file count (most predictable of response size). + * Secondary signal: symbol count (catches dense codebases with few but heavy files). + * Module count used as tiebreaker for edge cases. + */ + +export type ProjectSize = 'micro' | 'small' | 'medium' | 'large' | 'extra-large' + +export function classifyProject(totalFiles: number, totalSymbols: number, _totalModules: number): ProjectSize { + // File count is the primary signal — it most directly predicts response size + // (more files = more graph edges, module members, coupling pairs). + // Symbols only bump the classification UP by one tier if disproportionately high, + // catching dense codebases (few files but many exports/functions). + const byFiles = classifyByFiles(totalFiles) + const bySymbols = classifyBySymbols(totalSymbols) + + const filesIdx = SIZE_ORDER.indexOf(byFiles) + const symbolsIdx = SIZE_ORDER.indexOf(bySymbols) + + // Symbols can bump up by at most 1 tier (prevents 23-file project becoming "medium") + if (symbolsIdx > filesIdx) { + return SIZE_ORDER[Math.min(filesIdx + 1, symbolsIdx)]! + } + return byFiles +} + +const SIZE_ORDER: ProjectSize[] = ['micro', 'small', 'medium', 'large', 'extra-large'] + +function classifyByFiles(files: number): ProjectSize { + if (files <= 30) return 'micro' + if (files <= 200) return 'small' + if (files <= 2_000) return 'medium' + if (files <= 10_000) return 'large' + return 'extra-large' +} + +function classifyBySymbols(symbols: number): ProjectSize { + if (symbols <= 1_000) return 'micro' + if (symbols <= 5_000) return 'small' + if (symbols <= 50_000) return 'medium' + if (symbols <= 300_000) return 'large' + return 'extra-large' +} + +export interface SizeLimits { + // Constitution + constitutionModules: number + constitutionDeps: number + constitutionHotspots: number + constitutionCouplings: number + constitutionBugs: number + constitutionLessons: number + + // Module context (tool 2) + moduleDocCap: number + depModuleNameCap: number + depExternalCap: number + + // Dependency graph (tool 6) + graphEdgeCap: number + graphCallCap: number + graphFileEdgeCap: number + + // Other tools + symbolMatchCap: number + couplingCap: number + importersCap: number + decisionCap: number + decisionCharCap: number + sessionsCap: number + searchDefaultLimit: number + + // Module doc generation + moduleExportedSymbolCap: number + moduleFileSampleCap: number +} + +const LIMITS: Record = { + 'micro': { + constitutionModules: 100, constitutionDeps: 50, constitutionHotspots: 5, + constitutionCouplings: 5, constitutionBugs: 5, constitutionLessons: 3, + moduleDocCap: 20_000, depModuleNameCap: 50, depExternalCap: 30, + graphEdgeCap: 50, graphCallCap: 30, graphFileEdgeCap: 50, + symbolMatchCap: 50, couplingCap: 50, importersCap: 50, + decisionCap: 20, decisionCharCap: 4000, sessionsCap: 10, + searchDefaultLimit: 10, + moduleExportedSymbolCap: 50, moduleFileSampleCap: 5, + }, + 'small': { + constitutionModules: 50, constitutionDeps: 30, constitutionHotspots: 5, + constitutionCouplings: 5, constitutionBugs: 5, constitutionLessons: 3, + moduleDocCap: 15_000, depModuleNameCap: 30, depExternalCap: 20, + graphEdgeCap: 40, graphCallCap: 25, graphFileEdgeCap: 40, + symbolMatchCap: 40, couplingCap: 40, importersCap: 40, + decisionCap: 15, decisionCharCap: 3000, sessionsCap: 8, + searchDefaultLimit: 15, + moduleExportedSymbolCap: 40, moduleFileSampleCap: 5, + }, + 'medium': { + constitutionModules: 30, constitutionDeps: 25, constitutionHotspots: 5, + constitutionCouplings: 5, constitutionBugs: 5, constitutionLessons: 3, + moduleDocCap: 10_000, depModuleNameCap: 20, depExternalCap: 15, + graphEdgeCap: 25, graphCallCap: 15, graphFileEdgeCap: 25, + symbolMatchCap: 30, couplingCap: 30, importersCap: 30, + decisionCap: 10, decisionCharCap: 2000, sessionsCap: 5, + searchDefaultLimit: 20, + moduleExportedSymbolCap: 25, moduleFileSampleCap: 3, + }, + 'large': { + constitutionModules: 20, constitutionDeps: 20, constitutionHotspots: 5, + constitutionCouplings: 5, constitutionBugs: 5, constitutionLessons: 3, + moduleDocCap: 8_000, depModuleNameCap: 15, depExternalCap: 10, + graphEdgeCap: 15, graphCallCap: 10, graphFileEdgeCap: 20, + symbolMatchCap: 30, couplingCap: 30, importersCap: 30, + decisionCap: 10, decisionCharCap: 2000, sessionsCap: 5, + searchDefaultLimit: 20, + moduleExportedSymbolCap: 20, moduleFileSampleCap: 3, + }, + 'extra-large': { + constitutionModules: 15, constitutionDeps: 15, constitutionHotspots: 5, + constitutionCouplings: 5, constitutionBugs: 5, constitutionLessons: 3, + moduleDocCap: 6_000, depModuleNameCap: 10, depExternalCap: 8, + graphEdgeCap: 10, graphCallCap: 8, graphFileEdgeCap: 15, + symbolMatchCap: 20, couplingCap: 25, importersCap: 25, + decisionCap: 8, decisionCharCap: 1500, sessionsCap: 5, + searchDefaultLimit: 20, + moduleExportedSymbolCap: 15, moduleFileSampleCap: 3, + }, +} + +export type DetailLevel = 'brief' | 'full' + +/** Hard cap for "full" detail mode — prevents runaway responses. */ +const FULL_HARD_CAP: SizeLimits = { + constitutionModules: 100, constitutionDeps: 50, constitutionHotspots: 10, + constitutionCouplings: 10, constitutionBugs: 10, constitutionLessons: 5, + moduleDocCap: 50_000, depModuleNameCap: 100, depExternalCap: 50, + graphEdgeCap: 100, graphCallCap: 50, graphFileEdgeCap: 100, + symbolMatchCap: 100, couplingCap: 100, importersCap: 100, + decisionCap: 50, decisionCharCap: 10_000, sessionsCap: 20, + searchDefaultLimit: 50, + moduleExportedSymbolCap: 100, moduleFileSampleCap: 10, +} + +export function getSizeLimits(size: ProjectSize, detail: DetailLevel = 'brief'): SizeLimits { + if (detail === 'full') return FULL_HARD_CAP + return LIMITS[size] +} diff --git a/src/core/search.ts b/src/core/search.ts index 2a855f9..a47fea5 100644 --- a/src/core/search.ts +++ b/src/core/search.ts @@ -1,33 +1,216 @@ -import { readFile, listFiles, cortexPath } from '../utils/files.js' +import { readFile, cortexPath } from '../utils/files.js' import { readdir } from 'node:fs/promises' import { join } from 'node:path' import { existsSync } from 'node:fs' +import type { SymbolIndex, DependencyGraph } from '../types/index.js' export interface SearchResult { file: string line: number content: string context: string + type: 'symbol' | 'file' | 'doc' + score: number + kind?: string + signature?: string } -export async function searchKnowledge(projectRoot: string, query: string): Promise { +/** + * Unified search across symbols, file paths, and knowledge docs. + * + * Scoring: + * Symbols: base (exact=10, prefix=5, contains=3) + kind bonus + export bonus + * Kind bonus: function/class/interface/type/enum = +2, method = +1, const/variable/property = 0 + * Export bonus: exported = +1 + * File paths: 4 + * Docs: 2 + * + * Multi-word queries: splits on spaces and matches ALL words (AND logic). + */ +export async function searchKnowledge( + projectRoot: string, + query: string, + limit: number = 20, +): Promise { const cortexRoot = cortexPath(projectRoot) - if (!existsSync(cortexRoot)) return [] + if (!existsSync(cortexRoot) || !query.trim()) return [] + const queryLower = query.toLowerCase().trim() + const queryWords = queryLower.split(/\s+/).filter(w => w.length > 0) const results: SearchResult[] = [] - const queryLower = query.toLowerCase() - const allFiles = await getAllCortexFiles(cortexRoot) + // 1. Search symbols + const symbolResults = await searchSymbols(cortexRoot, queryLower, queryWords) + results.push(...symbolResults) - for (const filePath of allFiles) { + // 2. Search file paths from graph + const fileResults = await searchFilePaths(cortexRoot, queryLower, queryWords) + results.push(...fileResults) + + // 3. Search markdown knowledge docs + const docResults = await searchDocs(cortexRoot, queryLower, queryWords) + results.push(...docResults) + + // Deduplicate by file+line, keeping highest score + const seen = new Map() + for (const r of results) { + const key = `${r.file}:${r.line}` + const existing = seen.get(key) + if (!existing || r.score > existing.score) { + seen.set(key, r) + } + } + + // Sort by score desc, slice to limit + return [...seen.values()] + .sort((a, b) => b.score - a.score) + .slice(0, limit) +} + +// Definition-level symbols score higher than local variables +const HIGH_VALUE_KINDS = new Set(['function', 'class', 'interface', 'type', 'enum']) +const MID_VALUE_KINDS = new Set(['method']) + +async function searchSymbols(cortexRoot: string, queryLower: string, queryWords: string[]): Promise { + const content = await readFile(join(cortexRoot, 'symbols.json')) + if (!content) return [] + + let index: SymbolIndex + try { + index = JSON.parse(content) as SymbolIndex + } catch { + return [] + } + + const results: SearchResult[] = [] + + for (const sym of index.symbols) { + const nameLower = sym.name.toLowerCase() + // Also search the file path for multi-word queries (e.g. "gateway reconnect") + const fileLower = sym.file.toLowerCase() + + let baseScore = 0 + + if (queryWords.length > 1) { + // Multi-word: check if ALL words match across name + file path + const searchable = `${nameLower} ${fileLower}` + const allMatch = queryWords.every(w => searchable.includes(w)) + if (!allMatch) continue + + // Score based on how many words hit the symbol name vs just file path + const nameHits = queryWords.filter(w => nameLower.includes(w)).length + if (nameHits === queryWords.length) { + baseScore = 10 // all words in name + } else if (nameHits > 0) { + baseScore = 6 // some in name, rest in file + } else { + baseScore = 3 // all in file path only + } + } else { + // Single word: exact > prefix > contains + if (nameLower === queryLower) { + baseScore = 10 + } else if (nameLower.startsWith(queryLower)) { + baseScore = 5 + } else if (nameLower.includes(queryLower)) { + baseScore = 3 + } + } + + if (baseScore === 0) continue + + // Non-exported const/variable exact matches are usually local variable assignments + // (e.g. `const auth = resolveAuth(...)`) — cap them so definitions rank higher + const isLocalVar = !sym.exported && (sym.kind === 'const' || sym.kind === 'variable') + if (isLocalVar && baseScore === 10) { + baseScore = 5 // demote to same as prefix match + } + + // Kind bonus: definitions > local variables + let kindBonus = 0 + if (HIGH_VALUE_KINDS.has(sym.kind)) kindBonus = 2 + else if (MID_VALUE_KINDS.has(sym.kind)) kindBonus = 1 + + // Export bonus: exported symbols are more useful + const exportBonus = sym.exported ? 1 : 0 + + const score = baseScore + kindBonus + exportBonus + + results.push({ + file: sym.file, + line: sym.startLine, + content: sym.signature ?? `${sym.kind} ${sym.name}`, + context: `${sym.exported ? 'export ' : ''}${sym.kind} ${sym.name} — ${sym.file}:${sym.startLine}`, + type: 'symbol', + score, + kind: sym.kind, + signature: sym.signature, + }) + } + + return results +} + +async function searchFilePaths(cortexRoot: string, queryLower: string, queryWords: string[]): Promise { + const content = await readFile(join(cortexRoot, 'graph.json')) + if (!content) return [] + + let graph: DependencyGraph + try { + graph = JSON.parse(content) as DependencyGraph + } catch { + return [] + } + + const results: SearchResult[] = [] + const seen = new Set() + + // Collect all unique file paths from modules + for (const mod of graph.modules) { + for (const file of mod.files) { + if (seen.has(file)) continue + const fileLower = file.toLowerCase() + + const matches = queryWords.length > 1 + ? queryWords.every(w => fileLower.includes(w)) + : fileLower.includes(queryLower) + + if (matches) { + seen.add(file) + results.push({ + file, + line: 1, + content: file, + context: `File in module "${mod.name}" (${mod.language})`, + type: 'file', + score: 4, + }) + } + } + } + + return results +} + +async function searchDocs(cortexRoot: string, queryLower: string, queryWords: string[]): Promise { + const results: SearchResult[] = [] + const mdFiles = await getMarkdownFiles(cortexRoot) + + for (const filePath of mdFiles) { const content = await readFile(filePath) if (!content) continue const lines = content.split('\n') for (let i = 0; i < lines.length; i++) { const line = lines[i] - if (line?.toLowerCase().includes(queryLower)) { - // Get surrounding context (2 lines before and after) + if (!line) continue + const lineLower = line.toLowerCase() + + const matches = queryWords.length > 1 + ? queryWords.every(w => lineLower.includes(w)) + : lineLower.includes(queryLower) + + if (matches) { const start = Math.max(0, i - 2) const end = Math.min(lines.length - 1, i + 2) const context = lines.slice(start, end + 1).join('\n') @@ -37,6 +220,8 @@ export async function searchKnowledge(projectRoot: string, query: string): Promi line: i + 1, content: line.trim(), context, + type: 'doc', + score: 2, }) } } @@ -45,18 +230,17 @@ export async function searchKnowledge(projectRoot: string, query: string): Promi return results } -async function getAllCortexFiles(dir: string): Promise { +async function getMarkdownFiles(dir: string): Promise { const files: string[] = [] - if (!existsSync(dir)) return files const entries = await readdir(dir, { withFileTypes: true }) for (const entry of entries) { const fullPath = join(dir, entry.name) if (entry.isDirectory()) { - const subFiles = await getAllCortexFiles(fullPath) + const subFiles = await getMarkdownFiles(fullPath) files.push(...subFiles) - } else if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.json') || entry.name.endsWith('.yaml'))) { + } else if (entry.isFile() && entry.name.endsWith('.md')) { files.push(fullPath) } } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 7cd6ea1..9664c0d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -27,7 +27,7 @@ import { registerWriteTools } from './tools/write.js' export function createServer(projectRoot: string): McpServer { const server = new McpServer({ name: 'codecortex', - version: '0.4.4', + version: '0.5.0', description: 'Persistent codebase knowledge layer. Pre-digested architecture, symbols, coupling, and patterns served to AI agents.', }) diff --git a/src/mcp/tools/read.ts b/src/mcp/tools/read.ts index 28ed479..0c5c1d7 100644 --- a/src/mcp/tools/read.ts +++ b/src/mcp/tools/read.ts @@ -1,13 +1,15 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' import { readFile, cortexPath } from '../../utils/files.js' -import { readManifest } from '../../core/manifest.js' import { readGraph, getModuleDependencies, getMostImportedFiles, getFileImporters } from '../../core/graph.js' import { readModuleDoc, listModuleDocs } from '../../core/modules.js' import { listSessions, readSession, getLatestSession } from '../../core/sessions.js' import { listDecisions, readDecision } from '../../core/decisions.js' import { searchKnowledge } from '../../core/search.js' import { computeFreshness } from '../../core/freshness.js' +import { capString, truncateArray } from '../../utils/truncate.js' +import { readManifest } from '../../core/manifest.js' +import { getSizeLimits, type SizeLimits, type DetailLevel } from '../../core/project-size.js' import type { TemporalData, SymbolIndex, FreshnessInfo } from '../../types/index.js' function textResult(data: unknown) { @@ -22,7 +24,6 @@ function withFreshness(data: T, freshness: FreshnessInfo | nul export function registerReadTools(server: McpServer, projectRoot: string): void { // Cache freshness per MCP session to avoid repeated git calls. - // Invalidated when null (first call) or after 60 seconds. let cachedFreshness: { info: FreshnessInfo | null; timestamp: number } | null = null const FRESHNESS_TTL_MS = 60_000 @@ -36,17 +37,29 @@ export function registerReadTools(server: McpServer, projectRoot: string): void return info } + // Cache size-based limits per MCP session (project size doesn't change mid-session). + let cachedLimits: SizeLimits | null = null + + async function getLimits(detail: DetailLevel = 'brief'): Promise { + // 'full' always returns hard caps — no caching needed + if (detail === 'full') { + return getSizeLimits('large', 'full') + } + if (cachedLimits) return cachedLimits + const manifest = await readManifest(projectRoot) + cachedLimits = getSizeLimits(manifest?.projectSize ?? 'large') + return cachedLimits + } + // --- Tool 1: get_project_overview --- server.registerTool( 'get_project_overview', { - description: 'Get the full project overview: constitution (architecture, risk map, available knowledge), overview narrative, and dependency graph summary. This is the starting point for understanding any codebase. Always call this first.', + description: 'Get the project overview: constitution (architecture, risk map, available knowledge) and dependency graph summary. This is the starting point for understanding any codebase. Always call this first.', inputSchema: {}, }, async () => { const constitution = await readFile(cortexPath(projectRoot, 'constitution.md')) - const overview = await readFile(cortexPath(projectRoot, 'overview.md')) - const manifest = await readManifest(projectRoot) const graph = await readGraph(projectRoot) let graphSummary = null @@ -63,8 +76,6 @@ export function registerReadTools(server: McpServer, projectRoot: string): void return textResult(withFreshness({ constitution, - overview, - manifest, graphSummary, }, freshness)) } @@ -77,25 +88,61 @@ export function registerReadTools(server: McpServer, projectRoot: string): void description: 'Get deep context for a specific module: purpose, data flow, public API, gotchas, dependencies, and temporal signals (churn, coupling, bug history). Use after get_project_overview when you need to work on a specific module.', inputSchema: { name: z.string().describe('Module name (e.g., "scoring", "api", "indexer")'), + detail: z.enum(['brief', 'full']).default('brief').describe('Response detail level. "brief" (default) uses size-adaptive caps. "full" returns complete data (use only when you need exhaustive info).'), }, }, - async ({ name }) => { + async ({ name, detail }) => { + const limits = await getLimits(detail) const doc = await readModuleDoc(projectRoot, name) if (!doc) { const available = await listModuleDocs(projectRoot) return textResult({ found: false, name, available, message: `Module "${name}" not found. Available modules: ${available.join(', ')}` }) } - // Get graph info for this module + const cappedDoc = capString(doc, limits.moduleDocCap) + const graph = await readGraph(projectRoot) - let deps = null + let depSummary = null if (graph) { - deps = getModuleDependencies(graph, name) + const deps = getModuleDependencies(graph, name) + + const importsFrom = new Set() + const importedBy = new Set() + const externalDeps = new Set() + + for (const edge of deps.imports) { + const targetMod = graph.modules.find(m => m.files.includes(edge.target)) + if (targetMod && targetMod.name !== name) importsFrom.add(targetMod.name) + if (!edge.target.startsWith('.') && !edge.target.startsWith('/')) { + externalDeps.add(edge.target) + } + } + for (const edge of deps.importedBy) { + const sourceMod = graph.modules.find(m => m.files.includes(edge.source)) + if (sourceMod && sourceMod.name !== name) importedBy.add(sourceMod.name) + } + + const modFiles = new Set(graph.modules.find(m => m.name === name)?.files ?? []) + for (const [pkg, files] of Object.entries(graph.externalDeps)) { + if (files.some(f => modFiles.has(f))) externalDeps.add(pkg) + } + + const extDepsArr = [...externalDeps] + const importsFromArr = [...importsFrom] + const importedByArr = [...importedBy] + depSummary = { + importsFrom: importsFromArr.slice(0, limits.depModuleNameCap), + importedBy: importedByArr.slice(0, limits.depModuleNameCap), + totalImportsFrom: importsFromArr.length, + totalImportedBy: importedByArr.length, + externalDeps: extDepsArr.slice(0, limits.depExternalCap), + totalExternalDeps: extDepsArr.length, + } } const freshness = await getFreshness() - return textResult(withFreshness({ found: true, name, doc, dependencies: deps }, freshness)) + return textResult(withFreshness({ found: true, name, doc: cappedDoc, dependencies: depSummary }, freshness)) } ) @@ -120,7 +167,7 @@ export function registerReadTools(server: McpServer, projectRoot: string): void hasSession: true, latest: session, totalSessions: allSessions.length, - recentSessionIds: allSessions.slice(0, 5), + recentSessionIds: allSessions.slice(0, (await getLimits()).sessionsCap), }, freshness)) } ) @@ -129,19 +176,23 @@ export function registerReadTools(server: McpServer, projectRoot: string): void server.registerTool( 'search_knowledge', { - description: 'Search across all CodeCortex knowledge files (modules, decisions, patterns, sessions, constitution) for a keyword or phrase. Returns matching lines with context.', + description: 'Search across symbols (functions, classes, types), file paths, and knowledge docs. Returns ranked results: symbol definitions first, then file paths, then doc matches. Use instead of grep for finding code concepts.', inputSchema: { - query: z.string().describe('Search term or phrase'), + query: z.string().describe('Search term or phrase (e.g., "auth", "processData", "gateway")'), + limit: z.number().int().min(1).max(50).optional().describe('Max results to return. Defaults to size-adaptive limit.'), + detail: z.enum(['brief', 'full']).default('brief').describe('Response detail level. "brief" (default) uses size-adaptive caps. "full" returns more results.'), }, }, - async ({ query }) => { - const results = await searchKnowledge(projectRoot, query) + async ({ query, limit, detail }) => { + const limits = await getLimits(detail) + const effectiveLimit = limit ?? limits.searchDefaultLimit + const results = await searchKnowledge(projectRoot, query, effectiveLimit) const freshness = await getFreshness() return textResult(withFreshness({ query, totalResults: results.length, - results: results.slice(0, 20), + results, }, freshness)) } ) @@ -153,9 +204,11 @@ export function registerReadTools(server: McpServer, projectRoot: string): void description: 'Get architectural decision records. Shows WHY the codebase is built the way it is. Filter by topic keyword.', inputSchema: { topic: z.string().optional().describe('Optional keyword to filter decisions'), + detail: z.enum(['brief', 'full']).default('brief').describe('Response detail level. "brief" (default) uses size-adaptive caps. "full" returns complete data.'), }, }, - async ({ topic }) => { + async ({ topic, detail }) => { + const limits = await getLimits(detail) const ids = await listDecisions(projectRoot) const decisions: string[] = [] @@ -163,17 +216,19 @@ export function registerReadTools(server: McpServer, projectRoot: string): void const content = await readDecision(projectRoot, id) if (content) { if (!topic || content.toLowerCase().includes(topic.toLowerCase())) { - decisions.push(content) + decisions.push(capString(content, limits.decisionCharCap)) } } } + const capped = truncateArray(decisions, limits.decisionCap, 'decisions') const freshness = await getFreshness() return textResult(withFreshness({ - total: decisions.length, + total: capped.total, topic: topic || 'all', - decisions, + decisions: capped.items, + ...(capped.truncated ? { truncated: capped.message } : {}), }, freshness)) } ) @@ -182,30 +237,58 @@ export function registerReadTools(server: McpServer, projectRoot: string): void server.registerTool( 'get_dependency_graph', { - description: 'Get the import/export dependency graph. Shows which files import which, external dependencies, and entry points. Optionally filter to a specific file or module.', + description: 'Get the import/export dependency graph. Without filters, returns a summary dashboard. With a file or module filter, returns scoped edges (capped at 50). Use `name` for module filtering.', inputSchema: { file: z.string().optional().describe('Filter to edges involving this file path'), - module: z.string().optional().describe('Filter to edges involving this module name'), + name: z.string().optional().describe('Filter to edges involving this module name'), + module: z.string().optional().describe('(Deprecated — use `name`) Alias for name'), + detail: z.enum(['brief', 'full']).default('brief').describe('Response detail level. "brief" (default) uses size-adaptive caps. "full" returns complete data.'), }, }, - async ({ file, module }) => { + async ({ file, name, module, detail }) => { const graph = await readGraph(projectRoot) if (!graph) return textResult({ found: false, message: 'No graph data. Run codecortex init first.' }) const freshness = await getFreshness() - - if (module) { - const deps = getModuleDependencies(graph, module) - return textResult(withFreshness({ module, ...deps }, freshness)) + const mod = name || module + + const limits = await getLimits(detail) + + if (mod) { + const deps = getModuleDependencies(graph, mod) + return textResult(withFreshness({ + module: mod, + imports: deps.imports.slice(0, limits.graphEdgeCap), + importedBy: deps.importedBy.slice(0, limits.graphEdgeCap), + calls: deps.calls.slice(0, limits.graphCallCap), + totalImports: deps.imports.length, + totalImportedBy: deps.importedBy.length, + totalCalls: deps.calls.length, + }, freshness)) } if (file) { const imports = graph.imports.filter(e => e.source.includes(file) || e.target.includes(file)) const calls = graph.calls.filter(e => e.file.includes(file)) - return textResult(withFreshness({ file, imports, calls }, freshness)) + return textResult(withFreshness({ + file, + imports: imports.slice(0, limits.graphFileEdgeCap), + calls: calls.slice(0, limits.graphFileEdgeCap), + totalImports: imports.length, + totalCalls: calls.length, + }, freshness)) } - return textResult(withFreshness(graph, freshness)) + // No filter — return summary dashboard (never dump raw graph) + return textResult(withFreshness({ + summary: true, + modules: graph.modules.length, + imports: graph.imports.length, + calls: graph.calls.length, + entryPoints: graph.entryPoints, + externalDeps: Object.keys(graph.externalDeps), + topImported: getMostImportedFiles(graph, 10), + }, freshness)) } ) @@ -218,9 +301,10 @@ export function registerReadTools(server: McpServer, projectRoot: string): void name: z.string().describe('Symbol name to search for'), kind: z.enum(['function', 'class', 'interface', 'type', 'const', 'enum', 'method', 'property', 'variable']).optional().describe('Filter by symbol kind'), file: z.string().optional().describe('Filter by file path (partial match)'), + detail: z.enum(['brief', 'full']).default('brief').describe('Response detail level. "brief" (default) uses size-adaptive caps. "full" returns complete data.'), }, }, - async ({ name, kind, file }) => { + async ({ name, kind, file, detail }) => { const content = await readFile(cortexPath(projectRoot, 'symbols.json')) if (!content) return textResult({ found: false, message: 'No symbol index. Run codecortex init first.' }) @@ -237,7 +321,7 @@ export function registerReadTools(server: McpServer, projectRoot: string): void return textResult(withFreshness({ query: { name, kind, file }, totalMatches: matches.length, - symbols: matches.slice(0, 30), + symbols: matches.slice(0, (await getLimits(detail)).symbolMatchCap), }, freshness)) } ) @@ -250,9 +334,10 @@ export function registerReadTools(server: McpServer, projectRoot: string): void inputSchema: { file: z.string().optional().describe('Show coupling for this specific file'), minStrength: z.number().min(0).max(1).default(0.3).describe('Minimum coupling strength (0-1). Default 0.3.'), + detail: z.enum(['brief', 'full']).default('brief').describe('Response detail level. "brief" (default) uses size-adaptive caps. "full" returns complete data.'), }, }, - async ({ file, minStrength }) => { + async ({ file, minStrength, detail }) => { const content = await readFile(cortexPath(projectRoot, 'temporal.json')) if (!content) return textResult({ found: false, message: 'No temporal data. Run codecortex init first.' }) @@ -265,12 +350,16 @@ export function registerReadTools(server: McpServer, projectRoot: string): void ) } + const limits = await getLimits(detail) + const capped = truncateArray(coupling, limits.couplingCap, 'coupling pairs') const freshness = await getFreshness() return textResult(withFreshness({ file: file || 'all', minStrength, - couplings: coupling, + total: capped.total, + couplings: capped.items, + ...(capped.truncated ? { truncated: capped.message } : {}), warning: coupling.filter(c => !c.hasImport).length > 0 ? 'HIDDEN DEPENDENCIES FOUND — some coupled files have NO import between them' : null, @@ -338,12 +427,14 @@ export function registerReadTools(server: McpServer, projectRoot: string): void description: 'CALL THIS BEFORE EDITING FILES. Takes a list of files you plan to edit and returns everything you need to know: co-change warnings (files you must also update), risk assessment, who imports these files, relevant patterns, and recent change history. Prevents bugs from hidden dependencies.', inputSchema: { files: z.array(z.string()).min(1).describe('File paths you plan to edit (relative to project root)'), + detail: z.enum(['brief', 'full']).default('brief').describe('Response detail level. "brief" (default) uses size-adaptive caps. "full" returns complete data.'), }, }, - async ({ files }) => { + async ({ files, detail }) => { const temporalContent = await readFile(cortexPath(projectRoot, 'temporal.json')) if (!temporalContent) return textResult({ found: false, message: 'No temporal data. Run codecortex init first.' }) + const limits = await getLimits(detail) const temporal: TemporalData = JSON.parse(temporalContent) const graph = await readGraph(projectRoot) const patternsContent = await readFile(cortexPath(projectRoot, 'patterns.md')) @@ -377,10 +468,10 @@ export function registerReadTools(server: McpServer, projectRoot: string): void else if (riskScore >= 12) riskLevel = 'HIGH' else if (riskScore >= 6) riskLevel = 'MEDIUM' - // 3. Who imports this file + // 3. Who imports this file (capped) let importedBy: string[] = [] if (graph) { - importedBy = getFileImporters(graph, file) + importedBy = getFileImporters(graph, file).slice(0, limits.importersCap) } // 4. Recent changes diff --git a/src/types/index.ts b/src/types/index.ts index ca69f98..07a579c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -133,6 +133,8 @@ export interface SessionLog { // ─── Manifest (cortex.yaml) ─── +export type { ProjectSize } from '../core/project-size.js' + export interface CortexManifest { version: string project: string @@ -143,6 +145,7 @@ export interface CortexManifest { totalFiles: number totalSymbols: number totalModules: number + projectSize: import('../core/project-size.js').ProjectSize tiers: { hot: string[] warm: string[] diff --git a/src/utils/truncate.ts b/src/utils/truncate.ts new file mode 100644 index 0000000..91a84f1 --- /dev/null +++ b/src/utils/truncate.ts @@ -0,0 +1,72 @@ +/** + * Response truncation utilities for keeping MCP tool responses context-safe. + * Every read tool should return < 10K chars on large repos. + */ + +export interface TruncatedArray { + items: T[] + truncated: boolean + total: number + message?: string +} + +/** Truncate an array to a max length, with a summary of what was dropped. */ +export function truncateArray(arr: T[], limit: number, label: string): TruncatedArray { + if (arr.length <= limit) { + return { items: arr, truncated: false, total: arr.length } + } + return { + items: arr.slice(0, limit), + truncated: true, + total: arr.length, + message: `Showing ${limit} of ${arr.length} ${label}. Use filters to narrow results.`, + } +} + +/** Cap a string at a max character length. */ +export function capString(str: string, maxChars: number): string { + if (str.length <= maxChars) return str + return str.slice(0, maxChars) + '\n\n[truncated — use analyze_module for full detail]' +} + +export interface FileTypeSummary { + byType: Record + total: number +} + +/** Group files by type (implementation, tests, types, config) with counts and samples. */ +export function summarizeFileList(files: string[]): FileTypeSummary { + const groups: Record = { + tests: [], + types: [], + config: [], + implementation: [], + } + + for (const file of files) { + const lower = file.toLowerCase() + const name = file.split('/').pop() ?? file + + if (lower.includes('.test.') || lower.includes('.spec.') || lower.includes('__tests__') || lower.startsWith('test/') || lower.startsWith('tests/')) { + groups['tests']!.push(name) + } else if (lower.includes('.d.ts') || lower.includes('types') || lower.includes('.type.')) { + groups['types']!.push(name) + } else if (lower.includes('.config.') || lower.includes('.json') || lower.includes('.yaml') || lower.includes('.yml') || lower.includes('.toml') || lower.includes('.env')) { + groups['config']!.push(name) + } else { + groups['implementation']!.push(name) + } + } + + const byType: Record = {} + for (const [type, typeFiles] of Object.entries(groups)) { + if (typeFiles.length > 0) { + byType[type] = { + count: typeFiles.length, + sample: typeFiles.slice(0, 3), + } + } + } + + return { byType, total: files.length } +} diff --git a/tests/core/agent-instructions.test.ts b/tests/core/agent-instructions.test.ts new file mode 100644 index 0000000..82308de --- /dev/null +++ b/tests/core/agent-instructions.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm, mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { existsSync } from 'node:fs' +import { generateAgentInstructions, AGENT_INSTRUCTIONS } from '../../src/core/agent-instructions.js' + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'codecortex-agent-test-')) + await mkdir(join(root, '.codecortex'), { recursive: true }) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('generateAgentInstructions', () => { + it('creates AGENT.md in .codecortex/', async () => { + await generateAgentInstructions(root) + + const agentMd = await readFile(join(root, '.codecortex', 'AGENT.md'), 'utf-8') + expect(agentMd).toBe(AGENT_INSTRUCTIONS) + }) + + it('creates CLAUDE.md with pointer when none exists', async () => { + await generateAgentInstructions(root) + + const claudeMd = await readFile(join(root, 'CLAUDE.md'), 'utf-8') + expect(claudeMd).toContain('## CodeCortex') + expect(claudeMd).toContain('.codecortex/AGENT.md') + }) + + it('appends pointer to existing CLAUDE.md', async () => { + await writeFile(join(root, 'CLAUDE.md'), '# My Project\n\nSome instructions.\n', 'utf-8') + + await generateAgentInstructions(root) + + const claudeMd = await readFile(join(root, 'CLAUDE.md'), 'utf-8') + expect(claudeMd).toContain('# My Project') + expect(claudeMd).toContain('Some instructions.') + expect(claudeMd).toContain('## CodeCortex') + expect(claudeMd).toContain('.codecortex/AGENT.md') + }) + + it('is idempotent — does not duplicate on re-run', async () => { + await generateAgentInstructions(root) + await generateAgentInstructions(root) + + const claudeMd = await readFile(join(root, 'CLAUDE.md'), 'utf-8') + const matches = claudeMd.match(/## CodeCortex/g) + expect(matches).toHaveLength(1) + }) + + it('appends to .cursorrules if it exists', async () => { + await writeFile(join(root, '.cursorrules'), '# Cursor rules\n', 'utf-8') + + await generateAgentInstructions(root) + + const cursorrules = await readFile(join(root, '.cursorrules'), 'utf-8') + expect(cursorrules).toContain('# Cursor rules') + expect(cursorrules).toContain('## CodeCortex') + }) + + it('appends to .windsurfrules if it exists', async () => { + await writeFile(join(root, '.windsurfrules'), '# Windsurf rules\n', 'utf-8') + + await generateAgentInstructions(root) + + const windsurfrules = await readFile(join(root, '.windsurfrules'), 'utf-8') + expect(windsurfrules).toContain('# Windsurf rules') + expect(windsurfrules).toContain('## CodeCortex') + }) + + it('appends to AGENTS.md if it exists', async () => { + await writeFile(join(root, 'AGENTS.md'), '# Agents config\n', 'utf-8') + + await generateAgentInstructions(root) + + const agentsMd = await readFile(join(root, 'AGENTS.md'), 'utf-8') + expect(agentsMd).toContain('# Agents config') + expect(agentsMd).toContain('## CodeCortex') + }) + + it('appends to .github/copilot-instructions.md if it exists', async () => { + await mkdir(join(root, '.github'), { recursive: true }) + await writeFile(join(root, '.github/copilot-instructions.md'), '# Copilot\n', 'utf-8') + + await generateAgentInstructions(root) + + const copilot = await readFile(join(root, '.github/copilot-instructions.md'), 'utf-8') + expect(copilot).toContain('# Copilot') + expect(copilot).toContain('## CodeCortex') + }) + + it('returns list of updated files', async () => { + await writeFile(join(root, '.cursorrules'), '# Cursor\n', 'utf-8') + await writeFile(join(root, 'AGENTS.md'), '# Agents\n', 'utf-8') + + const updated = await generateAgentInstructions(root) + + expect(updated).toContain('AGENT.md') + expect(updated).toContain('.cursorrules') + expect(updated).toContain('AGENTS.md') + // CLAUDE.md also exists since it gets created as default? No — .cursorrules exists so CLAUDE.md only if it exists + }) + + it('AGENT.md contains all tool names', async () => { + await generateAgentInstructions(root) + + const agentMd = await readFile(join(root, '.codecortex', 'AGENT.md'), 'utf-8') + const expectedTools = [ + 'get_project_overview', 'search_knowledge', 'get_edit_briefing', + 'get_change_coupling', 'lookup_symbol', 'get_module_context', + 'get_dependency_graph', 'get_hotspots', 'get_decision_history', + 'get_session_briefing', 'record_decision', 'update_patterns', + 'analyze_module', 'save_module_analysis', 'report_feedback', + ] + for (const tool of expectedTools) { + expect(agentMd).toContain(tool) + } + }) +}) diff --git a/tests/core/module-gen.test.ts b/tests/core/module-gen.test.ts new file mode 100644 index 0000000..9314b98 --- /dev/null +++ b/tests/core/module-gen.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { createFixture, type Fixture } from '../fixtures/setup.js' +import { generateStructuralModuleDocs } from '../../src/core/module-gen.js' +import { readGraph } from '../../src/core/graph.js' +import { readModuleDoc } from '../../src/core/modules.js' +import type { SymbolRecord, TemporalData } from '../../src/types/index.js' + +let fixture: Fixture + +beforeAll(async () => { + fixture = await createFixture() +}) + +afterAll(async () => { + await fixture.cleanup() +}) + +describe('generateStructuralModuleDocs', () => { + it('generates module docs for all modules', async () => { + const graph = await readGraph(fixture.root) + expect(graph).not.toBeNull() + + const symbols: SymbolRecord[] = [ + { name: 'processData', kind: 'function', file: 'src/core/processor.ts', startLine: 5, endLine: 20, signature: 'export function processData()', exported: true }, + { name: 'formatOutput', kind: 'function', file: 'src/utils/format.ts', startLine: 3, endLine: 10, signature: 'export function formatOutput()', exported: true }, + ] + + const generated = await generateStructuralModuleDocs(fixture.root, { + graph: graph!, + symbols, + temporal: null, + }) + + expect(generated).toBe(2) // core + utils + }) + + it('uses grouped file summary instead of raw file list', async () => { + const doc = await readModuleDoc(fixture.root, 'core') + expect(doc).not.toBeNull() + + // Should contain grouped description, not raw comma-separated file list + // The module has .ts files so should show "implementation" group + expect(doc).toContain('implementation') + // Should NOT be a raw dump like "src/core/processor.ts, src/core/types.ts, ..." + expect(doc).not.toContain('src/core/processor.ts, src/core/types.ts, src/core/index.ts') + }) + + it('caps exported symbols at 20', async () => { + // The core module only has a few symbols, so test the cap logic directly + const graph = await readGraph(fixture.root) + expect(graph).not.toBeNull() + + // Create 55 fake exported symbols (exceeds micro cap of 50) + const manySymbols: SymbolRecord[] = Array.from({ length: 55 }, (_, i) => ({ + name: `func${i}`, + kind: 'function' as const, + file: 'src/utils/format.ts', + startLine: i * 10, + endLine: i * 10 + 5, + signature: `export function func${i}()`, + exported: true, + })) + + // Delete existing utils doc to allow regeneration + const { rm } = await import('node:fs/promises') + const { join } = await import('node:path') + try { + await rm(join(fixture.root, '.codecortex', 'modules', 'utils.md')) + } catch { /* may not exist */ } + + await generateStructuralModuleDocs(fixture.root, { + graph: graph!, + symbols: manySymbols, + temporal: null, + }) + + const doc = await readModuleDoc(fixture.root, 'utils') + expect(doc).not.toBeNull() + expect(doc).toContain('...and 5 more') + }) + + it('does not overwrite existing module docs', async () => { + const graph = await readGraph(fixture.root) + expect(graph).not.toBeNull() + + // core doc already exists from first test + const docBefore = await readModuleDoc(fixture.root, 'core') + + const generated = await generateStructuralModuleDocs(fixture.root, { + graph: graph!, + symbols: [], + temporal: null, + }) + + const docAfter = await readModuleDoc(fixture.root, 'core') + expect(docAfter).toBe(docBefore) // unchanged + expect(generated).toBe(0) // nothing new generated + }) +}) diff --git a/tests/core/project-size.test.ts b/tests/core/project-size.test.ts new file mode 100644 index 0000000..a525cae --- /dev/null +++ b/tests/core/project-size.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest' +import { classifyProject, getSizeLimits, type ProjectSize } from '../../src/core/project-size.js' + +describe('classifyProject', () => { + it('classifies micro projects', () => { + expect(classifyProject(5, 20, 1)).toBe('micro') + expect(classifyProject(23, 605, 6)).toBe('micro') + expect(classifyProject(30, 300, 3)).toBe('micro') + }) + + it('classifies small projects', () => { + expect(classifyProject(57, 1313, 7)).toBe('small') + expect(classifyProject(100, 1500, 10)).toBe('small') + expect(classifyProject(200, 3000, 15)).toBe('small') + }) + + it('classifies medium projects', () => { + expect(classifyProject(500, 8000, 30)).toBe('medium') + expect(classifyProject(1000, 15000, 50)).toBe('medium') + expect(classifyProject(2000, 40000, 60)).toBe('medium') + }) + + it('classifies large projects', () => { + expect(classifyProject(6403, 143428, 96)).toBe('large') + expect(classifyProject(6000, 120000, 80)).toBe('large') + }) + + it('classifies extra-large projects', () => { + expect(classifyProject(20000, 400000, 200)).toBe('extra-large') + expect(classifyProject(93000, 5300000, 500)).toBe('extra-large') + }) + + it('dense codebases bump up by one tier (symbols > files)', () => { + // 50 files (micro by files) but 10K symbols (medium by symbols) → bumps to small (one tier up) + expect(classifyProject(50, 10000, 5)).toBe('medium') + // Actually: files=50 → small (31-200), symbols=10000 → medium (5001-50000) + // symbolsIdx(2) > filesIdx(1), so min(1+1, 2) = 2 → medium + }) + + it('symbol bump is capped at one tier', () => { + // 10 files (micro) but 100K symbols (large) → bumps only to small, not large + expect(classifyProject(10, 100000, 2)).toBe('small') + }) +}) + +describe('getSizeLimits', () => { + it('returns different limits for each size', () => { + const sizes: ProjectSize[] = ['micro', 'small', 'medium', 'large', 'extra-large'] + + for (const size of sizes) { + const limits = getSizeLimits(size) + expect(limits.moduleDocCap).toBeGreaterThan(0) + expect(limits.graphEdgeCap).toBeGreaterThan(0) + expect(limits.symbolMatchCap).toBeGreaterThan(0) + } + }) + + it('micro has the highest limits (least truncation)', () => { + const micro = getSizeLimits('micro') + const large = getSizeLimits('large') + + expect(micro.moduleDocCap).toBeGreaterThan(large.moduleDocCap) + expect(micro.graphEdgeCap).toBeGreaterThan(large.graphEdgeCap) + expect(micro.symbolMatchCap).toBeGreaterThan(large.symbolMatchCap) + expect(micro.depModuleNameCap).toBeGreaterThan(large.depModuleNameCap) + }) + + it('limits decrease monotonically from micro to extra-large', () => { + const sizes: ProjectSize[] = ['micro', 'small', 'medium', 'large', 'extra-large'] + const allLimits = sizes.map(getSizeLimits) + + for (let i = 0; i < allLimits.length - 1; i++) { + expect(allLimits[i]!.moduleDocCap).toBeGreaterThanOrEqual(allLimits[i + 1]!.moduleDocCap) + expect(allLimits[i]!.graphEdgeCap).toBeGreaterThanOrEqual(allLimits[i + 1]!.graphEdgeCap) + } + }) +}) diff --git a/tests/core/search.test.ts b/tests/core/search.test.ts index bc03730..86af7fc 100644 --- a/tests/core/search.test.ts +++ b/tests/core/search.test.ts @@ -12,39 +12,119 @@ afterAll(async () => { await fixture.cleanup() }) -describe('searchKnowledge', () => { - it('finds matches in constitution', async () => { - const results = await searchKnowledge(fixture.root, 'test-project') +describe('searchKnowledge — unified search', () => { + // Symbol search + it('finds symbols by exact name match (base 10 + kind/export bonus)', async () => { + const results = await searchKnowledge(fixture.root, 'processData') expect(results.length).toBeGreaterThan(0) - expect(results.some(r => r.file === 'constitution.md')).toBe(true) + const top = results[0]! + expect(top.type).toBe('symbol') + // base 10 (exact) + 2 (function) + 1 (exported) = 13 + expect(top.score).toBe(13) + expect(top.kind).toBe('function') + expect(top.file).toContain('processor.ts') }) - it('finds matches in overview', async () => { - const results = await searchKnowledge(fixture.root, 'Entry Points') + it('finds symbols by prefix match (base 5 + bonuses)', async () => { + const results = await searchKnowledge(fixture.root, 'process') + const symbols = results.filter(r => r.type === 'symbol') + expect(symbols.length).toBeGreaterThanOrEqual(2) // processData + processAuth + // prefix=5 + function=2 + exported=1 = 8 + expect(symbols[0]!.score).toBe(8) + }) + + it('finds symbols by substring match with kind/export bonus', async () => { + const results = await searchKnowledge(fixture.root, 'auth') + const symbols = results.filter(r => r.type === 'symbol') + expect(symbols.length).toBeGreaterThanOrEqual(2) // authenticate + processAuth + expect(symbols.some(s => s.content.includes('authenticate'))).toBe(true) + }) + + it('exported functions score higher than unexported consts', async () => { + const results = await searchKnowledge(fixture.root, 'format') + const symbols = results.filter(r => r.type === 'symbol') + if (symbols.length >= 1) { + // formatOutput: exported function → prefix=5 + fn=2 + exported=1 = 8 + expect(symbols[0]!.score).toBeGreaterThanOrEqual(8) + } + }) + + it('handles multi-word queries (AND logic)', async () => { + // "process auth" should find processAuth (both words in name) + const results = await searchKnowledge(fixture.root, 'process auth') + expect(results.length).toBeGreaterThan(0) + expect(results.some(r => r.content.includes('processAuth'))).toBe(true) + }) + + it('demotes non-exported const/variable exact matches', async () => { + // TIMEOUT is a non-exported const — exact match should be capped at 5 (not 10) + const results = await searchKnowledge(fixture.root, 'TIMEOUT') + const timeout = results.find(r => r.kind === 'const' && r.content.includes('TIMEOUT')) + expect(timeout).toBeDefined() + // Non-exported const exact: base=5 (capped from 10) + kind=0 + export=0 = 5 + expect(timeout!.score).toBe(5) + }) + + // File path search + it('finds file paths from graph (score 4)', async () => { + const results = await searchKnowledge(fixture.root, 'processor') + const fileResults = results.filter(r => r.type === 'file') + expect(fileResults.length).toBeGreaterThanOrEqual(1) + expect(fileResults[0]!.file).toContain('processor.ts') + expect(fileResults[0]!.score).toBe(4) + }) + + // Markdown doc search + it('finds matches in constitution markdown (score 2)', async () => { + const results = await searchKnowledge(fixture.root, 'Architecture') + const docs = results.filter(r => r.type === 'doc') + expect(docs.length).toBeGreaterThan(0) + expect(docs[0]!.score).toBe(2) + }) + + // Ranking + it('ranks symbols above files above docs', async () => { + // 'format' matches: symbol formatOutput (prefix=5 + fn=2 + exported=1 = 8), file format.ts (4), possibly docs (2) + const results = await searchKnowledge(fixture.root, 'format') expect(results.length).toBeGreaterThan(0) - expect(results.some(r => r.file === 'overview.md')).toBe(true) + // First result should be the symbol (score 8), not file (4) or doc (2) + expect(results[0]!.score).toBeGreaterThanOrEqual(4) }) + // Limit + it('respects limit parameter', async () => { + const results = await searchKnowledge(fixture.root, 'test', 3) + expect(results.length).toBeLessThanOrEqual(3) + }) + + // Case insensitive it('is case-insensitive', async () => { - const results = await searchKnowledge(fixture.root, 'TYPESCRIPT') + const results = await searchKnowledge(fixture.root, 'PROCESSDATA') expect(results.length).toBeGreaterThan(0) + expect(results[0]!.type).toBe('symbol') }) + // Empty/missing it('returns empty for non-matching query', async () => { const results = await searchKnowledge(fixture.root, 'xyzzy_nonexistent_12345') expect(results).toHaveLength(0) }) - it('includes line number and context', async () => { - const results = await searchKnowledge(fixture.root, 'Architecture') - const hit = results[0] - expect(hit).toBeDefined() - expect(hit!.line).toBeGreaterThan(0) - expect(hit!.context.length).toBeGreaterThan(0) + it('returns empty for empty query', async () => { + const results = await searchKnowledge(fixture.root, '') + expect(results).toHaveLength(0) }) it('returns empty for missing .codecortex/', async () => { const results = await searchKnowledge('/tmp/nonexistent-codecortex-test', 'anything') expect(results).toHaveLength(0) }) + + // Deduplication + it('deduplicates results by file+line', async () => { + const results = await searchKnowledge(fixture.root, 'processData') + const keys = results.map(r => `${r.file}:${r.line}`) + const uniqueKeys = new Set(keys) + expect(keys.length).toBe(uniqueKeys.size) + }) }) diff --git a/tests/fixtures/setup.ts b/tests/fixtures/setup.ts index 6c1dbf1..69926d8 100644 --- a/tests/fixtures/setup.ts +++ b/tests/fixtures/setup.ts @@ -31,9 +31,10 @@ lastUpdated: 2026-03-02T00:00:00.000Z languages: - typescript - python -totalFiles: 5 -totalSymbols: 12 +totalFiles: 6 +totalSymbols: 14 totalModules: 2 +projectSize: micro tiers: hot: - cortex.yaml @@ -58,8 +59,8 @@ tiers: ## Project - **Name:** test-project - **Languages:** typescript, python -- **Files:** 5 -- **Symbols:** 12 +- **Files:** 6 +- **Symbols:** 14 - **Modules:** 2 ## Architecture @@ -75,7 +76,7 @@ tiers: **Type:** node **Languages:** typescript, python -**Files:** 5 +**Files:** 6 ## Entry Points - \`src/index.ts\` @@ -88,12 +89,14 @@ tiers: // symbols.json const symbols = { generated: '2026-03-02T00:00:00.000Z', - total: 4, + total: 6, // 4 original + 2 auth symbols symbols: [ { name: 'processData', kind: 'function', file: 'src/core/processor.ts', startLine: 5, endLine: 20, signature: 'export function processData(input: string): Result', exported: true }, { name: 'Result', kind: 'interface', file: 'src/core/types.ts', startLine: 1, endLine: 8, signature: 'export interface Result', exported: true }, { name: 'formatOutput', kind: 'function', file: 'src/utils/format.ts', startLine: 3, endLine: 10, signature: 'export function formatOutput(data: Result): string', exported: true }, { name: 'TIMEOUT', kind: 'const', file: 'src/utils/config.ts', startLine: 1, endLine: 1, signature: 'const TIMEOUT = 5000', exported: false }, + { name: 'authenticate', kind: 'function', file: 'src/core/auth.ts', startLine: 1, endLine: 15, signature: 'export function authenticate(token: string): boolean', exported: true }, + { name: 'processAuth', kind: 'function', file: 'src/core/auth.ts', startLine: 20, endLine: 30, signature: 'export function processAuth(req: Request): User', exported: true }, ], } await writeFile(join(cortex, 'symbols.json'), JSON.stringify(symbols, null, 2)) @@ -102,7 +105,7 @@ tiers: const graph = { generated: '2026-03-02T00:00:00.000Z', modules: [ - { path: 'src/core', name: 'core', files: ['src/core/processor.ts', 'src/core/types.ts', 'src/core/index.ts'], language: 'typescript', lines: 200, symbols: 2 }, + { path: 'src/core', name: 'core', files: ['src/core/processor.ts', 'src/core/types.ts', 'src/core/index.ts', 'src/core/auth.ts'], language: 'typescript', lines: 250, symbols: 4 }, { path: 'src/utils', name: 'utils', files: ['src/utils/format.ts', 'src/utils/config.ts'], language: 'typescript', lines: 100, symbols: 2 }, ], imports: [ diff --git a/tests/mcp/read-tools.test.ts b/tests/mcp/read-tools.test.ts index 2889c1e..a18fcdc 100644 --- a/tests/mcp/read-tools.test.ts +++ b/tests/mcp/read-tools.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { createFixture, type Fixture } from '../fixtures/setup.js' import { readFile, cortexPath } from '../../src/utils/files.js' -import { readManifest } from '../../src/core/manifest.js' +import { readManifest, updateManifest } from '../../src/core/manifest.js' import { readGraph, getModuleDependencies, getMostImportedFiles, getFileImporters } from '../../src/core/graph.js' import { readModuleDoc, listModuleDocs } from '../../src/core/modules.js' import { listSessions, getLatestSession } from '../../src/core/sessions.js' @@ -46,11 +46,39 @@ describe('get_project_overview (tool 1)', () => { const manifest = await readManifest(fixture.root) expect(manifest).not.toBeNull() expect(manifest!.project).toBe('test-project') - expect(manifest!.totalFiles).toBe(5) - expect(manifest!.totalSymbols).toBe(12) + expect(manifest!.totalFiles).toBe(6) + expect(manifest!.totalSymbols).toBe(14) expect(manifest!.totalModules).toBe(2) }) + it('updateManifest recalculates projectSize when file count changes', async () => { + // Fixture starts as micro (6 files, 14 symbols, 2 modules) + const before = await readManifest(fixture.root) + expect(before!.projectSize).toBe('micro') + + // Grow to small (500 files) + const updated = await updateManifest(fixture.root, { totalFiles: 500 }) + expect(updated).not.toBeNull() + expect(updated!.projectSize).toBe('medium') // 500 files = medium + + // Restore to original + await updateManifest(fixture.root, { totalFiles: 6 }) + const restored = await readManifest(fixture.root) + expect(restored!.projectSize).toBe('micro') + }) + + it('response has no overview key (removed in v0.5.0)', async () => { + // The tool now returns only constitution + graphSummary, no overview or manifest + const constitution = await readFile(cortexPath(fixture.root, 'constitution.md')) + const graph = await readGraph(fixture.root) + const response = { constitution, graphSummary: graph ? { modules: graph.modules.length } : null } + + expect(response).not.toHaveProperty('overview') + expect(response).not.toHaveProperty('manifest') + expect(response).toHaveProperty('constitution') + expect(response).toHaveProperty('graphSummary') + }) + it('reads graph summary', async () => { const graph = await readGraph(fixture.root) expect(graph).not.toBeNull() @@ -106,6 +134,35 @@ describe('search_knowledge (tool 4)', () => { const limited = results.slice(0, 20) expect(limited.length).toBeLessThanOrEqual(20) }) + + it('respects custom limit param', async () => { + const results = await searchKnowledge(fixture.root, 'process', 2) + expect(results.length).toBeLessThanOrEqual(2) + }) + + it('finds symbols by name with type=symbol', async () => { + const results = await searchKnowledge(fixture.root, 'processData') + const symbolResults = results.filter(r => r.type === 'symbol') + expect(symbolResults.length).toBeGreaterThan(0) + // exact(10) + function(2) + exported(1) = 13 + expect(symbolResults[0]!.score).toBeGreaterThanOrEqual(10) + }) + + it('searchDefaultLimit exists in size limits', async () => { + const { getSizeLimits } = await import('../../src/core/project-size.js') + const micro = getSizeLimits('micro') + const large = getSizeLimits('large') + expect(micro.searchDefaultLimit).toBe(10) + expect(large.searchDefaultLimit).toBe(20) + }) + + it('ranks symbols higher than file paths and docs', async () => { + const results = await searchKnowledge(fixture.root, 'auth') + if (results.length >= 2) { + // First result should be highest scored + expect(results[0]!.score).toBeGreaterThanOrEqual(results[1]!.score) + } + }) }) describe('get_decision_history (tool 5)', () => { @@ -141,6 +198,28 @@ describe('get_dependency_graph (tool 6)', () => { expect(imports.length).toBeGreaterThanOrEqual(2) // processor imports types + format imports types }) + + it('accepts name param (same as module)', async () => { + const graph = await readGraph(fixture.root) + const depsByName = getModuleDependencies(graph!, 'core') + const depsByModule = getModuleDependencies(graph!, 'core') + + expect(depsByName.imports.length).toBe(depsByModule.imports.length) + expect(depsByName.importedBy.length).toBe(depsByModule.importedBy.length) + }) + + it('unfiltered graph provides summary-compatible data', async () => { + const graph = await readGraph(fixture.root) + expect(graph).not.toBeNull() + + // These are the fields the summary dashboard uses + expect(graph!.modules.length).toBe(2) + expect(graph!.entryPoints).toEqual(['src/index.ts']) + expect(Object.keys(graph!.externalDeps).length).toBeGreaterThan(0) + + const topImported = getMostImportedFiles(graph!, 10) + expect(topImported.length).toBeGreaterThan(0) + }) }) describe('lookup_symbol (tool 7)', () => { @@ -307,3 +386,36 @@ describe('get_edit_briefing (tool 10)', () => { expect(hotspot).toBeUndefined() }) }) + +describe('detail flag (brief vs full)', () => { + it('getSizeLimits returns higher caps for full detail', async () => { + const { getSizeLimits } = await import('../../src/core/project-size.js') + const brief = getSizeLimits('large') + const full = getSizeLimits('large', 'full') + + expect(full.moduleDocCap).toBeGreaterThan(brief.moduleDocCap) + expect(full.graphEdgeCap).toBeGreaterThan(brief.graphEdgeCap) + expect(full.symbolMatchCap).toBeGreaterThan(brief.symbolMatchCap) + expect(full.decisionCap).toBeGreaterThan(brief.decisionCap) + expect(full.couplingCap).toBeGreaterThan(brief.couplingCap) + }) + + it('full detail returns same caps regardless of project size', async () => { + const { getSizeLimits } = await import('../../src/core/project-size.js') + const microFull = getSizeLimits('micro', 'full') + const largeFull = getSizeLimits('large', 'full') + + expect(microFull.moduleDocCap).toBe(largeFull.moduleDocCap) + expect(microFull.graphEdgeCap).toBe(largeFull.graphEdgeCap) + expect(microFull.symbolMatchCap).toBe(largeFull.symbolMatchCap) + }) + + it('brief detail varies by project size', async () => { + const { getSizeLimits } = await import('../../src/core/project-size.js') + const microBrief = getSizeLimits('micro') + const largeBrief = getSizeLimits('large') + + expect(microBrief.moduleDocCap).toBeGreaterThan(largeBrief.moduleDocCap) + expect(microBrief.graphEdgeCap).toBeGreaterThan(largeBrief.graphEdgeCap) + }) +}) diff --git a/tests/utils/truncate.test.ts b/tests/utils/truncate.test.ts new file mode 100644 index 0000000..0f6b0db --- /dev/null +++ b/tests/utils/truncate.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { truncateArray, capString, summarizeFileList } from '../../src/utils/truncate.js' + +describe('truncateArray', () => { + it('returns all items when under limit', () => { + const result = truncateArray([1, 2, 3], 5, 'items') + expect(result.items).toEqual([1, 2, 3]) + expect(result.truncated).toBe(false) + expect(result.total).toBe(3) + expect(result.message).toBeUndefined() + }) + + it('truncates when over limit', () => { + const result = truncateArray([1, 2, 3, 4, 5], 3, 'results') + expect(result.items).toEqual([1, 2, 3]) + expect(result.truncated).toBe(true) + expect(result.total).toBe(5) + expect(result.message).toContain('3 of 5') + expect(result.message).toContain('results') + }) + + it('handles exact limit', () => { + const result = truncateArray([1, 2, 3], 3, 'items') + expect(result.truncated).toBe(false) + expect(result.items).toHaveLength(3) + }) + + it('handles empty array', () => { + const result = truncateArray([], 10, 'items') + expect(result.items).toEqual([]) + expect(result.truncated).toBe(false) + expect(result.total).toBe(0) + }) +}) + +describe('capString', () => { + it('returns string unchanged when under limit', () => { + expect(capString('short', 100)).toBe('short') + }) + + it('truncates long strings with notice', () => { + const long = 'a'.repeat(200) + const result = capString(long, 100) + expect(result).toHaveLength(100 + '\n\n[truncated — use analyze_module for full detail]'.length) + expect(result).toContain('[truncated') + }) + + it('handles exact length', () => { + const str = 'exact' + expect(capString(str, 5)).toBe('exact') + }) +}) + +describe('summarizeFileList', () => { + it('groups files by type', () => { + const files = [ + 'src/core/auth.ts', + 'src/core/auth.test.ts', + 'src/types/index.d.ts', + 'tsconfig.json', + ] + const result = summarizeFileList(files) + expect(result.total).toBe(4) + expect(result.byType['implementation']?.count).toBe(1) + expect(result.byType['tests']?.count).toBe(1) + expect(result.byType['types']?.count).toBe(1) + expect(result.byType['config']?.count).toBe(1) + }) + + it('caps samples at 3 per type', () => { + const files = [ + 'src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts', 'src/e.ts', + ] + const result = summarizeFileList(files) + expect(result.byType['implementation']?.sample).toHaveLength(3) + expect(result.byType['implementation']?.count).toBe(5) + }) + + it('handles empty file list', () => { + const result = summarizeFileList([]) + expect(result.total).toBe(0) + expect(Object.keys(result.byType)).toHaveLength(0) + }) + + it('omits empty groups', () => { + const files = ['src/main.ts', 'src/lib.ts'] + const result = summarizeFileList(files) + expect(result.byType['tests']).toBeUndefined() + expect(result.byType['types']).toBeUndefined() + expect(result.byType['config']).toBeUndefined() + expect(result.byType['implementation']?.count).toBe(2) + }) +}) From 244bee0b2afb58436377bde9b8c5b71b9d09629e Mon Sep 17 00:00:00 2001 From: Rushikesh More Date: Mon, 9 Mar 2026 00:46:44 +0530 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20v0.5.0=20positioning=20=E2=80=94=20?= =?UTF-8?q?navigation=20+=20risk=20layer,=2015=E2=86=9213=20tools,=20valid?= =?UTF-8?q?ated=20claims?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repositions CodeCortex from "persistent codebase memory" to "codebase navigation and risk layer" based on 3 rounds of agent comparison testing on a 6,400-file repo. Key changes: - Tools 15→13: removed analyze_module + save_module_analysis, renamed report_feedback → record_observation - AGENT.md rewritten: "CodeCortex finds WHERE to look. You still read the code." - README rewritten with real test data (~50% fewer tokens, 2.5x fewer tool calls, same quality 23/25 vs 23/25) - CLI modules command capped at 30 edges (was 559KB for large modules) - Dropped unvalidated "85% token reduction" claim - All 276 tests pass, tsc clean, build succeeds Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 8 +- README.md | 106 ++++++++++---------- package.json | 2 +- src/cli/commands/modules.ts | 19 +++- src/core/agent-instructions.ts | 37 +++---- src/core/module-gen.ts | 2 +- src/mcp/server.ts | 4 +- src/mcp/tools/read.ts | 4 +- src/mcp/tools/write.ts | 137 ++++---------------------- src/utils/truncate.ts | 2 +- tests/core/agent-instructions.test.ts | 2 +- tests/mcp/simulation.test.ts | 20 +--- tests/mcp/write-tools.test.ts | 45 +++++---- tests/utils/truncate.test.ts | 2 +- 14 files changed, 151 insertions(+), 239 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2329794..2012e0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CodeCortex -Persistent, AI-powered codebase knowledge layer. Pre-digests codebases into structured knowledge and serves to AI agents via MCP. +Codebase navigation and risk layer for AI agents. Pre-builds a map of architecture, dependencies, coupling, and risk areas so agents go straight to the right files. ## Stack - TypeScript, ESM (`"type": "module"`) @@ -49,9 +49,9 @@ Hybrid extraction: - `codecortex hook install|uninstall|status` - manage git hooks for auto-update - `codecortex upgrade` - check for and install latest version -## MCP Tools (15) +## MCP Tools (13) Read (10): get_project_overview, get_module_context, get_session_briefing, search_knowledge, get_decision_history, get_dependency_graph, lookup_symbol, get_change_coupling, get_hotspots, get_edit_briefing -Write (5): analyze_module, save_module_analysis, record_decision, update_patterns, report_feedback +Write (3): record_decision, update_patterns, record_observation All read tools include `_freshness` metadata (status, lastAnalyzed, filesChangedSince, changedFiles, message). All read tools return context-safe responses (<10K chars) via truncation utilities in `src/utils/truncate.ts`. @@ -72,7 +72,7 @@ Run ALL of these before `npm publish`. Do not skip any step. - **Grammar smoke test** (`parser.test.ts`): Loads every language in `LANGUAGE_LOADERS` via `parseSource()`. Catches missing packages, broken native builds, wrong require paths. This is what would have caught the tree-sitter-liquid issue. - **Version-check tests**: Update notification, cache lifecycle, PM detection, upgrade commands. - **Hook tests**: Git hook install/uninstall/status integration tests. -- **MCP tests**: All 15 tools (read + write), simulation tests. +- **MCP tests**: All 13 tools (read + write), simulation tests. ### Known limitations - tree-sitter native bindings don't compile on Node 24 yet (upstream issue) diff --git a/README.md b/README.md index 6323b94..6128750 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CodeCortex -Persistent codebase knowledge layer for AI agents. Your AI shouldn't re-learn your codebase every session. +Codebase navigation and risk layer for AI agents. Pre-builds a map of architecture, dependencies, coupling, and risk areas so agents go straight to the right files. [![CI](https://github.com/rushikeshmore/CodeCortex/actions/workflows/ci.yml/badge.svg)](https://github.com/rushikeshmore/CodeCortex/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/codecortex-ai)](https://www.npmjs.com/package/codecortex-ai) @@ -8,9 +8,6 @@ Persistent codebase knowledge layer for AI agents. Your AI shouldn't re-learn yo [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/rushikeshmore/CodeCortex/badge)](https://scorecard.dev/viewer/?uri=github.com/rushikeshmore/CodeCortex) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/rushikeshmore/CodeCortex/blob/main/LICENSE) -> **⚠️ If you're on v0.4.x or earlier, update now:** `npm install -g codecortex-ai@latest` -> v0.5.0 adds context-safe response caps on all tools, ranked symbol search, agent auto-onboarding, and parameter consistency fixes. - [Website](https://codecortex-ai.vercel.app) · [npm](https://www.npmjs.com/package/codecortex-ai) · [GitHub](https://github.com/rushikeshmore/CodeCortex) @@ -19,18 +16,40 @@ Persistent codebase knowledge layer for AI agents. Your AI shouldn't re-learn yo ## The Problem -Every AI coding session starts from scratch. When context compacts or a new session begins, the AI re-scans the entire codebase. Same files, same tokens, same wasted time. It's like hiring a new developer every session who has to re-learn everything before writing a single line. +Every AI coding session starts with exploration — grepping, reading wrong files, re-discovering architecture. On a 6,000-file codebase, an agent makes 37 tool calls and burns 79K tokens just to understand what's where. And it still can't tell you which files are dangerous to edit or which files secretly depend on each other. **The data backs this up:** - AI agents increase defect risk by 30% on unfamiliar code ([CodeScene + Lund University, 2025](https://codescene.com/hubfs/whitepapers/AI-Ready-Code-How-Code-Health-Determines-AI-Performance.pdf)) - Code churn grew 2.5x in the AI era ([GitClear, 211M lines analyzed](https://www.gitclear.com/coding_on_copilot_data_shows_ais_downward_pressure_on_code_quality)) -- Nobody combines structural + semantic + temporal + decision knowledge in one portable tool ## The Solution -CodeCortex pre-digests codebases into layered knowledge files and serves them to any AI agent via MCP. Instead of re-understanding your codebase every session, the AI starts with knowledge. +CodeCortex gives agents a pre-built map: architecture, dependencies, risk areas, hidden coupling. The agent goes straight to the right files and starts working. -**Hybrid extraction:** tree-sitter native N-API for structure (symbols, imports, calls across 27 languages) + host LLM for semantics (what modules do, why they're built that way). Zero extra API keys. +**CodeCortex finds WHERE to look. Your agent still reads the code.** + +Tested on a real 6,400-file codebase (143K symbols, 96 modules): + +| | Without CodeCortex | With CodeCortex | +|--|:--:|:--:| +| Tool calls | 37 | **15** (2.5x fewer) | +| Total tokens | 79K | **43K** (~50% fewer) | +| Answer quality | 23/25 | **23/25** (same) | +| Hidden dependencies found | No | **Yes** | + +### What makes it unique + +Three capabilities no other tool provides: + +1. **Temporal coupling** — Files that always change together but have zero imports between them. You can read every line and never discover this. Only git co-change analysis reveals it. + +2. **Risk scores** — File X has been bug-fixed 7 times, has 6 hidden dependencies, and co-changes with 3 other files. Risk score: 35. You can't learn this from reading code. + +3. **Cross-session memory** — Decisions, patterns, observations persist. The agent doesn't start from zero each session. + +**Example from a real codebase:** +- `schema.help.ts` and `schema.labels.ts` co-changed in 12/14 commits (86%) with **zero imports between them** +- Without this knowledge, an AI editing one file would produce a bug 86% of the time ## Quick Start @@ -44,9 +63,6 @@ npm install -g codecortex-ai --legacy-peer-deps cd /path/to/your-project codecortex init -# Start MCP server (for AI agent access) -codecortex serve - # Check knowledge freshness codecortex status ``` @@ -97,7 +113,8 @@ All knowledge lives in `.codecortex/` as flat files in your repo: graph.json # dependency graph (imports, calls, modules) symbols.json # full symbol index (functions, classes, types...) temporal.json # git coupling, hotspots, bug history - modules/*.md # per-module deep analysis + AGENT.md # tool usage guide for AI agents + modules/*.md # per-module structural analysis decisions/*.md # architectural decision records sessions/*.md # session change logs patterns.md # coding patterns and conventions @@ -109,47 +126,42 @@ All knowledge lives in `.codecortex/` as flat files in your repo: |-------|------|------| | 1. Structural | Modules, deps, symbols, entry points | `graph.json` + `symbols.json` | | 2. Semantic | What each module does, data flow, gotchas | `modules/*.md` | -| 3. Temporal | Git behavioral fingerprint - coupling, hotspots, bug history | `temporal.json` | +| 3. Temporal | Git behavioral fingerprint — coupling, hotspots, bug history | `temporal.json` | | 4. Decisions | Why things are built this way | `decisions/*.md` | | 5. Patterns | How code is written here | `patterns.md` | | 6. Sessions | What changed between sessions | `sessions/*.md` | -### The Temporal Layer - -This is the killer differentiator. The temporal layer tells agents *"if you touch file X, you MUST also touch file Y"* even when there's no import between them. This comes from git co-change analysis, not static code analysis. +## MCP Tools (13) -Example from a real codebase: -- `routes.ts` and `worker.ts` co-changed in 9/12 commits (75%) with **zero imports between them** -- Without this knowledge, an AI editing one file would produce a bug 75% of the time +### Navigation — "Where should I look?" (4 tools) -## MCP Tools (15) +| Tool | Description | +|------|-------------| +| `get_project_overview` | Architecture, modules, risk map. Call this first. | +| `search_knowledge` | Find where a function/class/type is DEFINED by name. Ranked results. | +| `lookup_symbol` | Precise symbol lookup with kind and file path filters. | +| `get_module_context` | Module files, deps, temporal signals. Zoom into a module. | -### Read Tools (10) +### Risk — "What could go wrong?" (4 tools) | Tool | Description | |------|-------------| -| `get_project_overview` | Constitution + graph summary (context-safe, ~2K chars) | -| `get_module_context` | Module doc by name, includes temporal signals (capped at 8K) | -| `get_session_briefing` | Changes since last session | -| `search_knowledge` | Ranked search across symbols, file paths, and docs | -| `get_decision_history` | Decision records filtered by topic (capped at 10) | -| `get_dependency_graph` | Summary dashboard or scoped edges (capped at 50) | -| `lookup_symbol` | Symbol by name/file/kind | -| `get_change_coupling` | What files must I also edit if I touch X? | -| `get_hotspots` | Files ranked by risk (churn x coupling) | -| `get_edit_briefing` | **NEW** — Pre-edit risk briefing: co-change warnings, hidden deps, bug history, importers | - -All read tools include `_freshness` metadata indicating how up-to-date the knowledge is. +| `get_edit_briefing` | Pre-edit risk: co-change warnings, hidden deps, bug history. **Always call before editing.** | +| `get_hotspots` | Files ranked by risk (churn x coupling x bugs). | +| `get_change_coupling` | Files that must change together. Hidden dependencies flagged. | +| `get_dependency_graph` | Import/export graph filtered by module or file. | -### Write Tools (5) +### Memory — "Remember this" (5 tools) | Tool | Description | |------|-------------| -| `analyze_module` | Returns source files + structured prompt for LLM analysis | -| `save_module_analysis` | Persists LLM analysis to `modules/*.md` | -| `record_decision` | Saves architectural decision to `decisions/*.md` | -| `update_patterns` | Merges coding pattern into `patterns.md` | -| `report_feedback` | Agent reports incorrect knowledge for next analysis | +| `get_session_briefing` | What changed since the last session. | +| `get_decision_history` | Why things were built this way. | +| `record_decision` | Save an architectural decision. | +| `update_patterns` | Document coding conventions. | +| `record_observation` | Record anything you learned about the codebase. | + +All read tools include `_freshness` metadata and return context-safe responses (<10K chars) via size-adaptive caps. ## CLI Commands @@ -160,25 +172,19 @@ All read tools include `_freshness` metadata indicating how up-to-date the knowl | `codecortex update` | Re-extract changed files, update affected modules | | `codecortex status` | Show knowledge freshness, stale modules, symbol counts | | `codecortex symbols [query]` | Browse and filter the symbol index | -| `codecortex search ` | Search across all CodeCortex knowledge files | +| `codecortex search ` | Search across symbols, file paths, and docs | | `codecortex modules [name]` | List modules or deep-dive into a specific module | | `codecortex hotspots` | Show files ranked by risk: churn + coupling + bug history | | `codecortex hook install\|uninstall\|status` | Manage git hooks for auto-updating knowledge | | `codecortex upgrade` | Check for and install the latest version | -## Token Efficiency - -CodeCortex uses a three-tier memory model to minimize token usage: +## How It Works -``` -Session start (HOT only): ~4,300 tokens -Working on a module (+WARM): ~5,000 tokens -Need coding patterns (+COLD): ~5,900 tokens +**Hybrid extraction:** tree-sitter native N-API for structure (symbols, imports, calls across 27 languages) + host LLM for semantics (what modules do, why they're built that way). Zero extra API keys. -vs. raw scan of entire codebase: ~37,800 tokens -``` +**Git hooks** keep knowledge fresh — `codecortex update` runs automatically on every commit, re-extracting changed files and updating temporal analysis. -85-90% token reduction. 7-10x efficiency gain. +**Size-adaptive responses** — CodeCortex classifies your project (micro → extra-large) and adjusts response caps accordingly. A 23-file project gets full detail. A 6,400-file project gets intelligent summaries. Every MCP tool response stays under 10K chars. ## Supported Languages (27) diff --git a/package.json b/package.json index f0c7be1..0454d58 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codecortex-ai", "version": "0.5.0", - "description": "Permanent codebase memory for AI agents — extracts symbols, deps, and patterns, serves via MCP", + "description": "Codebase navigation and risk layer for AI agents — architecture, dependencies, coupling, and risk areas served via MCP", "type": "module", "bin": { "codecortex": "dist/cli/index.js" diff --git a/src/cli/commands/modules.ts b/src/cli/commands/modules.ts index d2dd910..0baec4e 100644 --- a/src/cli/commands/modules.ts +++ b/src/cli/commands/modules.ts @@ -81,32 +81,45 @@ async function printModuleDetail( console.log(`Run \`codecortex init\` to generate structural docs.`) } - // Dependencies + // Dependencies (capped to prevent terminal flood on large modules) const deps = getModuleDependencies(graph, name) + const EDGE_CAP = 30 if (deps.imports.length > 0) { console.log('') - console.log('Imports:') + console.log(`Imports (${deps.imports.length} total):`) const seen = new Set() + let shown = 0 for (const edge of deps.imports) { + if (shown >= EDGE_CAP) break const key = `${edge.source} -> ${edge.target}` if (seen.has(key)) continue seen.add(key) const specifiers = edge.specifiers.length > 0 ? ` [${edge.specifiers.join(', ')}]` : '' console.log(` ${edge.source} -> ${edge.target}${specifiers}`) + shown++ + } + if (deps.imports.length > EDGE_CAP) { + console.log(` ...and ${deps.imports.length - EDGE_CAP} more. Use MCP tools for full graph.`) } } if (deps.importedBy.length > 0) { console.log('') - console.log('Imported By:') + console.log(`Imported By (${deps.importedBy.length} total):`) const seen = new Set() + let shown = 0 for (const edge of deps.importedBy) { + if (shown >= EDGE_CAP) break const key = `${edge.source} -> ${edge.target}` if (seen.has(key)) continue seen.add(key) const specifiers = edge.specifiers.length > 0 ? ` [${edge.specifiers.join(', ')}]` : '' console.log(` ${edge.source} -> ${edge.target}${specifiers}`) + shown++ + } + if (deps.importedBy.length > EDGE_CAP) { + console.log(` ...and ${deps.importedBy.length - EDGE_CAP} more. Use MCP tools for full graph.`) } } diff --git a/src/core/agent-instructions.ts b/src/core/agent-instructions.ts index 8a571f9..187d4fe 100644 --- a/src/core/agent-instructions.ts +++ b/src/core/agent-instructions.ts @@ -5,37 +5,38 @@ import { writeFile, ensureDir, cortexPath } from '../utils/files.js' const CODECORTEX_SECTION_MARKER = '## CodeCortex' -export const AGENT_INSTRUCTIONS = `# CodeCortex — Codebase Knowledge Tools +export const AGENT_INSTRUCTIONS = `# CodeCortex — Codebase Navigation & Risk Tools -This project uses CodeCortex for persistent codebase knowledge. These MCP tools give you pre-analyzed context — prefer them over raw Read/Grep/Glob. +This project uses CodeCortex. It gives you a pre-built map of the codebase — architecture, dependencies, risk areas, hidden coupling. Use it to navigate to the right files, then read those files with your normal tools. -## Orientation (start here) +**CodeCortex finds WHERE to look. You still read the code.** + +## Navigation (start here) - \`get_project_overview\` — architecture, modules, risk map. Call this first. -- \`search_knowledge\` — search functions, types, files, and docs by keyword. Faster than grep for concepts. +- \`search_knowledge\` — find where a function/class/type is DEFINED by name. Ranked results: exported definitions first. NOT for content search — use grep for that. +- \`lookup_symbol\` — precise symbol lookup with kind + file path filters. Use when you know exactly what you're looking for (e.g., "all interfaces in gateway/"). +- \`get_module_context\` — what files, symbols, and deps are in a specific module. +- \`get_dependency_graph\` — import/export graph filtered by file or module. +- \`get_session_briefing\` — what changed since the last session. + +## When to use grep instead +- "How does X work?" → grep (searches file contents) +- "Find all usage of X" → grep (finds every occurrence) +- "Where is X defined?" → \`search_knowledge\` or \`lookup_symbol\` (finds definitions, ranked) ## Before Editing (ALWAYS call these) -- \`get_edit_briefing\` — co-change risks, hidden dependencies, bug history for files you plan to edit. +- \`get_edit_briefing\` — co-change risks, hidden dependencies, bug history for files you plan to edit. Prevents bugs from files that secretly change together. - \`get_change_coupling\` — files that historically change together. Missing one causes bugs. -- \`lookup_symbol\` — find where a function/class/type is defined. - -## Deep Dive -- \`get_module_context\` — purpose, API, gotchas, and dependencies of a specific module. -- \`get_dependency_graph\` — import/export graph filtered by file or module. - \`get_hotspots\` — files ranked by risk (churn + coupling + bugs). -- \`get_decision_history\` — architectural decisions and their rationale. -- \`get_session_briefing\` — what changed since the last session. ## Response Detail Control -These tools accept a \`detail\` parameter (\`"brief"\` or \`"full"\`): \`get_module_context\`, \`get_dependency_graph\`, \`get_decision_history\`, \`lookup_symbol\`, \`get_change_coupling\`, \`search_knowledge\`, \`get_edit_briefing\`. -- **brief** (default) — size-adaptive caps. Small projects show more, large projects truncate aggressively. Best for exploration. -- **full** — returns complete data up to hard safety limits. Use when you need exhaustive info for a specific analysis. -Only use \`detail: "full"\` when brief results are insufficient — it increases response size significantly on large codebases. +Most tools accept \`detail: "brief"\` (default) or \`"full"\`. Use brief for exploration, full only when you need exhaustive data. ## Building Knowledge (call as you work) - \`record_decision\` — when you make a non-obvious technical choice, record WHY. - \`update_patterns\` — when you discover a coding convention, document it. -- \`analyze_module\` + \`save_module_analysis\` — deep-analyze a module's purpose and API. -- \`report_feedback\` — if any CodeCortex knowledge is wrong or outdated, report it. +- \`record_observation\` — record anything you learned (gotchas, undocumented deps, env requirements). +- \`get_decision_history\` — check what decisions were already made and why. ` const CLAUDEMD_POINTER = ` diff --git a/src/core/module-gen.ts b/src/core/module-gen.ts index 9e7442d..5f7197d 100644 --- a/src/core/module-gen.ts +++ b/src/core/module-gen.ts @@ -102,7 +102,7 @@ export async function generateStructuralModuleDocs( const analysis: ModuleAnalysis = { name: mod.name, - purpose: `${mod.files.length} files, ${mod.lines} lines (${mod.language}). Auto-generated from code structure — use \`analyze_module\` MCP tool for semantic analysis.`, + purpose: `${mod.files.length} files, ${mod.lines} lines (${mod.language}). Auto-generated from code structure. Updated on each commit via git hooks.`, dataFlow, publicApi: cappedExported, gotchas: [], diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 9664c0d..bb10eff 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -2,7 +2,7 @@ * CodeCortex MCP Server * * Serves codebase knowledge to AI agents via Model Context Protocol. - * 15 tools: 10 read (knowledge retrieval) + 5 write (knowledge creation). + * 13 tools: 10 read (navigation + risk) + 3 write (knowledge creation). * * Usage: * codecortex serve @@ -28,7 +28,7 @@ export function createServer(projectRoot: string): McpServer { const server = new McpServer({ name: 'codecortex', version: '0.5.0', - description: 'Persistent codebase knowledge layer. Pre-digested architecture, symbols, coupling, and patterns served to AI agents.', + description: 'Codebase navigation and risk layer for AI agents. Pre-built map of architecture, dependencies, coupling, and risk areas.', }) registerReadTools(server, projectRoot) diff --git a/src/mcp/tools/read.ts b/src/mcp/tools/read.ts index 0c5c1d7..0c8de03 100644 --- a/src/mcp/tools/read.ts +++ b/src/mcp/tools/read.ts @@ -176,7 +176,7 @@ export function registerReadTools(server: McpServer, projectRoot: string): void server.registerTool( 'search_knowledge', { - description: 'Search across symbols (functions, classes, types), file paths, and knowledge docs. Returns ranked results: symbol definitions first, then file paths, then doc matches. Use instead of grep for finding code concepts.', + description: 'Find where a function, class, type, or file is DEFINED. Returns ranked results: exported definitions first, local vars demoted. For content/concept search ("how does X work?"), use grep instead — this tool searches symbol names, not file contents.', inputSchema: { query: z.string().describe('Search term or phrase (e.g., "auth", "processData", "gateway")'), limit: z.number().int().min(1).max(50).optional().describe('Max results to return. Defaults to size-adaptive limit.'), @@ -296,7 +296,7 @@ export function registerReadTools(server: McpServer, projectRoot: string): void server.registerTool( 'lookup_symbol', { - description: 'Look up a symbol (function, class, type, interface, const) by name. Returns file path, line numbers, signature, and whether it\'s exported. Use to find where something is defined.', + description: 'Precise symbol lookup with kind and file path filters. Use when you know what you\'re looking for — e.g., "all interfaces in gateway/" or "the function named processData". Returns file path, line numbers, signature, exported status.', inputSchema: { name: z.string().describe('Symbol name to search for'), kind: z.enum(['function', 'class', 'interface', 'type', 'const', 'enum', 'method', 'property', 'variable']).optional().describe('Filter by symbol kind'), diff --git a/src/mcp/tools/write.ts b/src/mcp/tools/write.ts index e831c35..ac30fcf 100644 --- a/src/mcp/tools/write.ts +++ b/src/mcp/tools/write.ts @@ -1,117 +1,16 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' import { readFile as readFileUtil, cortexPath } from '../../utils/files.js' -import { ModuleAnalysisSchema, DecisionInputSchema, PatternInputSchema, FeedbackInputSchema } from '../../types/schema.js' -import { writeModuleDoc, buildAnalysisPrompt, listModuleDocs } from '../../core/modules.js' import { writeDecision, createDecision } from '../../core/decisions.js' import { addPattern } from '../../core/patterns.js' import { writeFile, ensureDir } from '../../utils/files.js' -import { readGraph } from '../../core/graph.js' -import type { ModuleAnalysis, TemporalData, Hotspot, ChangeCoupling, BugRecord } from '../../types/index.js' -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' function textResult(data: unknown) { return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] } } export function registerWriteTools(server: McpServer, projectRoot: string): void { - // --- Tool 10: analyze_module --- - server.registerTool( - 'analyze_module', - { - description: 'Prepares a module for analysis. Returns the source files and a structured prompt. You should read the source files, analyze them, then call save_module_analysis with the result.', - inputSchema: { - name: z.string().describe('Module name (e.g., "scoring", "api")'), - }, - }, - async ({ name }) => { - const graph = await readGraph(projectRoot) - if (!graph) { - return textResult({ error: 'No graph data. Run codecortex init first.' }) - } - - const module = graph.modules.find(m => m.name === name) - if (!module) { - const available = graph.modules.map(m => m.name) - return textResult({ error: `Module "${name}" not found`, available }) - } - - // Read source files for this module - const sourceFiles: { path: string; content: string }[] = [] - for (const filePath of module.files) { - try { - const content = await readFile(join(projectRoot, filePath), 'utf-8') - sourceFiles.push({ path: filePath, content }) - } catch { - // Skip files that can't be read - } - } - - const prompt = buildAnalysisPrompt(name, sourceFiles) - - return textResult({ - module: name, - files: module.files, - prompt, - instruction: 'Analyze the source files above and call save_module_analysis with the JSON result.', - }) - } - ) - - // --- Tool 11: save_module_analysis --- - server.registerTool( - 'save_module_analysis', - { - description: 'Save the result of a module analysis. Provide the structured analysis (purpose, dataFlow, publicApi, gotchas, dependencies) and it will be persisted to modules/*.md.', - inputSchema: { - analysis: ModuleAnalysisSchema.describe('The structured module analysis'), - }, - }, - async ({ analysis }) => { - const moduleAnalysis: ModuleAnalysis = { - ...analysis, - } - - // Enrich with temporal data if available - const temporalContent = await readFileUtil(cortexPath(projectRoot, 'temporal.json')) - if (temporalContent) { - const temporal: TemporalData = JSON.parse(temporalContent) - const hotspot = temporal.hotspots?.find((h: Hotspot) => - h.file.includes(`/${analysis.name}/`) || h.file.includes(`${analysis.name}.`) - ) - const couplings = temporal.coupling?.filter((c: ChangeCoupling) => - c.fileA.includes(`/${analysis.name}/`) || c.fileB.includes(`/${analysis.name}/`) - ) || [] - const bugs = temporal.bugHistory?.filter((b: BugRecord) => - b.file.includes(`/${analysis.name}/`) - ) || [] - - if (hotspot || couplings.length > 0 || bugs.length > 0) { - moduleAnalysis.temporalSignals = { - churn: hotspot ? `${hotspot.changes} changes (${hotspot.stability})` : 'unknown', - coupledWith: couplings.map((c: ChangeCoupling) => { - const other = c.fileA.includes(`/${analysis.name}/`) ? c.fileB : c.fileA - return `${other} (${c.cochanges} co-changes)` - }), - stability: hotspot?.stability || 'unknown', - bugHistory: bugs.flatMap((b: BugRecord) => b.lessons), - lastChanged: hotspot?.lastChanged || 'unknown', - } - } - } - - await writeModuleDoc(projectRoot, moduleAnalysis) - - return textResult({ - saved: true, - module: analysis.name, - path: `.codecortex/modules/${analysis.name}.md`, - }) - } - ) - - // --- Tool 12: record_decision --- + // --- Tool 11: record_decision --- server.registerTool( 'record_decision', { @@ -136,7 +35,7 @@ export function registerWriteTools(server: McpServer, projectRoot: string): void } ) - // --- Tool 13: update_patterns --- + // --- Tool 12: update_patterns --- server.registerTool( 'update_patterns', { @@ -159,39 +58,41 @@ export function registerWriteTools(server: McpServer, projectRoot: string): void } ) - // --- Tool 14: report_feedback --- + // --- Tool 13: record_observation --- server.registerTool( - 'report_feedback', + 'record_observation', { - description: 'Report incorrect or outdated knowledge. If you discover that a module doc, decision, or pattern is wrong, report it here. The feedback will be stored and used in the next analysis cycle.', + description: 'Record something you learned about the codebase. Use this to capture observations, gotchas, undocumented dependencies, environment requirements, or anything future agents should know. Observations persist across sessions.', inputSchema: { - file: z.string().describe('Which knowledge file is incorrect (e.g., "modules/scoring.md")'), - issue: z.string().describe('What is wrong or outdated'), + topic: z.string().describe('Short topic label (e.g., "circular dependency in auth", "Docker required for tests")'), + observation: z.string().describe('What you observed or learned'), + files: z.array(z.string()).default([]).describe('Related file paths (optional)'), reporter: z.string().default('agent').describe('Who is reporting (default: agent)'), }, }, - async ({ file, issue, reporter }) => { - const dir = cortexPath(projectRoot, 'feedback') + async ({ topic, observation, files, reporter }) => { + const dir = cortexPath(projectRoot, 'observations') await ensureDir(dir) const entry = { date: new Date().toISOString(), - file, - issue, + topic, + observation, + files, reporter, } - // Append to feedback log - const feedbackPath = cortexPath(projectRoot, 'feedback', 'log.json') - const existing = await readFileUtil(feedbackPath) + // Append to observations log + const obsPath = cortexPath(projectRoot, 'observations', 'log.json') + const existing = await readFileUtil(obsPath) const entries = existing ? JSON.parse(existing) : [] entries.push(entry) - await writeFile(feedbackPath, JSON.stringify(entries, null, 2)) + await writeFile(obsPath, JSON.stringify(entries, null, 2)) return textResult({ recorded: true, - totalFeedback: entries.length, - message: 'Feedback recorded. Will be addressed in next codecortex update.', + totalObservations: entries.length, + message: 'Observation recorded. Future agents will see this.', }) } ) diff --git a/src/utils/truncate.ts b/src/utils/truncate.ts index 91a84f1..ddd57d5 100644 --- a/src/utils/truncate.ts +++ b/src/utils/truncate.ts @@ -26,7 +26,7 @@ export function truncateArray(arr: T[], limit: number, label: string): Trunca /** Cap a string at a max character length. */ export function capString(str: string, maxChars: number): string { if (str.length <= maxChars) return str - return str.slice(0, maxChars) + '\n\n[truncated — use analyze_module for full detail]' + return str.slice(0, maxChars) + '\n\n[truncated — use detail: "full" for complete data]' } export interface FileTypeSummary { diff --git a/tests/core/agent-instructions.test.ts b/tests/core/agent-instructions.test.ts index 82308de..fa8f2c5 100644 --- a/tests/core/agent-instructions.test.ts +++ b/tests/core/agent-instructions.test.ts @@ -115,7 +115,7 @@ describe('generateAgentInstructions', () => { 'get_change_coupling', 'lookup_symbol', 'get_module_context', 'get_dependency_graph', 'get_hotspots', 'get_decision_history', 'get_session_briefing', 'record_decision', 'update_patterns', - 'analyze_module', 'save_module_analysis', 'report_feedback', + 'record_observation', ] for (const tool of expectedTools) { expect(agentMd).toContain(tool) diff --git a/tests/mcp/simulation.test.ts b/tests/mcp/simulation.test.ts index b7a04f8..ca7c9eb 100644 --- a/tests/mcp/simulation.test.ts +++ b/tests/mcp/simulation.test.ts @@ -18,7 +18,7 @@ import { createFixture, type Fixture } from '../fixtures/setup.js' import { readFile, writeFile, cortexPath, ensureDir } from '../../src/utils/files.js' import { readManifest } from '../../src/core/manifest.js' import { readGraph, getModuleDependencies, getMostImportedFiles } from '../../src/core/graph.js' -import { readModuleDoc, writeModuleDoc, listModuleDocs, buildAnalysisPrompt } from '../../src/core/modules.js' +import { readModuleDoc, writeModuleDoc, listModuleDocs } from '../../src/core/modules.js' import { writeDecision, createDecision, listDecisions, readDecision } from '../../src/core/decisions.js' import { writeSession, createSession, listSessions, readSession, getLatestSession } from '../../src/core/sessions.js' import { addPattern, readPatterns } from '../../src/core/patterns.js' @@ -210,8 +210,8 @@ describe('Persona 3: Feature Developer — write workflow', () => { expect(graph!.modules.map(m => m.name)).toContain('utils') }) - it('Step 2: calls analyze_module for "utils"', async () => { - // Tool: analyze_module { name: "utils" } + it('Step 2: inspects "utils" module from graph and writes structural doc', async () => { + // Agent reads the graph to understand module structure const graph = await readGraph(fixture.root) const module = graph!.modules.find(m => m.name === 'utils') @@ -219,19 +219,7 @@ describe('Persona 3: Feature Developer — write workflow', () => { expect(module!.files).toContain('src/utils/format.ts') expect(module!.files).toContain('src/utils/config.ts') - // In real flow, source files would be read and a prompt generated - const prompt = buildAnalysisPrompt('utils', [ - { path: 'src/utils/format.ts', content: 'export function formatOutput(data: any): string { return JSON.stringify(data) }' }, - { path: 'src/utils/config.ts', content: 'const TIMEOUT = 5000; export { TIMEOUT }' }, - ]) - - expect(prompt).toContain('utils') - expect(prompt).toContain('formatOutput') - expect(prompt).toContain('purpose') - }) - - it('Step 3: calls save_module_analysis with structured analysis', async () => { - // Tool: save_module_analysis { analysis: {...} } + // Agent writes a module doc based on what it learned from reading code const analysis: ModuleAnalysis = { name: 'utils', purpose: 'Shared utility functions for formatting output and configuration constants.', diff --git a/tests/mcp/write-tools.test.ts b/tests/mcp/write-tools.test.ts index 7885ed7..eb1b12e 100644 --- a/tests/mcp/write-tools.test.ts +++ b/tests/mcp/write-tools.test.ts @@ -25,7 +25,7 @@ afterAll(async () => { await fixture.cleanup() }) -describe('save_module_analysis (tool 11)', () => { +describe('module doc write/read (used by structural gen)', () => { it('writes module doc and can read it back', async () => { const analysis: ModuleAnalysis = { name: 'core', @@ -50,7 +50,7 @@ describe('save_module_analysis (tool 11)', () => { }) }) -describe('record_decision (tool 12)', () => { +describe('record_decision (tool 11)', () => { it('writes decision and reads it back', async () => { const decision = createDecision({ title: 'Use tree-sitter for parsing', @@ -74,7 +74,7 @@ describe('record_decision (tool 12)', () => { }) }) -describe('update_patterns (tool 13)', () => { +describe('update_patterns (tool 12)', () => { it('adds a new pattern', async () => { const result = await addPattern(fixture.root, { name: 'Error Handling', @@ -105,45 +105,48 @@ describe('update_patterns (tool 13)', () => { }) }) -describe('report_feedback (tool 14)', () => { - it('records feedback entry', async () => { - const dir = cortexPath(fixture.root, 'feedback') +describe('record_observation (tool 13)', () => { + it('records an observation entry', async () => { + const dir = cortexPath(fixture.root, 'observations') await ensureDir(dir) const entry = { date: new Date().toISOString(), - file: 'modules/core.md', - issue: 'Purpose description is outdated', + topic: 'circular dependency in auth', + observation: 'Auth module imports from user module which imports back from auth', + files: ['src/auth/index.ts', 'src/user/index.ts'], reporter: 'agent', } - const feedbackPath = cortexPath(fixture.root, 'feedback', 'log.json') - const existing = await readFile(feedbackPath) + const obsPath = cortexPath(fixture.root, 'observations', 'log.json') + const existing = await readFile(obsPath) const entries = existing ? JSON.parse(existing) : [] entries.push(entry) - await writeFile(feedbackPath, JSON.stringify(entries, null, 2)) + await writeFile(obsPath, JSON.stringify(entries, null, 2)) // Read back - const content = await readFile(feedbackPath) + const content = await readFile(obsPath) const parsed = JSON.parse(content!) expect(parsed).toHaveLength(1) - expect(parsed[0].issue).toBe('Purpose description is outdated') + expect(parsed[0].topic).toBe('circular dependency in auth') + expect(parsed[0].observation).toContain('Auth module') expect(parsed[0].reporter).toBe('agent') }) - it('appends multiple feedback entries', async () => { - const feedbackPath = cortexPath(fixture.root, 'feedback', 'log.json') - const existing = await readFile(feedbackPath) + it('appends multiple observation entries', async () => { + const obsPath = cortexPath(fixture.root, 'observations', 'log.json') + const existing = await readFile(obsPath) const entries = existing ? JSON.parse(existing) : [] entries.push({ date: new Date().toISOString(), - file: 'patterns.md', - issue: 'Missing pattern for logging', - reporter: 'user', + topic: 'Docker required for tests', + observation: 'Integration tests need Docker running for the database container', + files: ['docker-compose.yml'], + reporter: 'agent', }) - await writeFile(feedbackPath, JSON.stringify(entries, null, 2)) + await writeFile(obsPath, JSON.stringify(entries, null, 2)) - const content = await readFile(feedbackPath) + const content = await readFile(obsPath) const parsed = JSON.parse(content!) expect(parsed).toHaveLength(2) }) diff --git a/tests/utils/truncate.test.ts b/tests/utils/truncate.test.ts index 0f6b0db..8242275 100644 --- a/tests/utils/truncate.test.ts +++ b/tests/utils/truncate.test.ts @@ -41,7 +41,7 @@ describe('capString', () => { it('truncates long strings with notice', () => { const long = 'a'.repeat(200) const result = capString(long, 100) - expect(result).toHaveLength(100 + '\n\n[truncated — use analyze_module for full detail]'.length) + expect(result).toHaveLength(100 + '\n\n[truncated — use detail: "full" for complete data]'.length) expect(result).toContain('[truncated') })