diff --git a/Readme.md b/Readme.md index 6fcfbef..4ee0087 100644 --- a/Readme.md +++ b/Readme.md @@ -18,7 +18,67 @@ node server.js You can browse the apis at +## API + +### Notes + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/notes` | Create a note | +| GET | `/notes` | List notes (optional filters) | +| GET | `/notes/:noteId` | Get one note | +| PUT | `/notes/:noteId` | Update a note | +| DELETE | `/notes/:noteId` | Delete a note | + +### Note fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `title` | string | No | Defaults to `"Untitled Note"` | +| `content` | string | Yes | Note body | +| `tags` | string[] | No | Labels for organizing notes; defaults to `[]` | + +### Create a note + +```bash +curl -X POST http://localhost:3000/notes \ + -H "Content-Type: application/json" \ + -d '{"title":"Q3 Budget","content":"Review expenses","tags":["work","finance"]}' +``` + +Omitting `tags` creates a note with an empty tags array. + +### List and search notes + +```bash +# All notes +curl http://localhost:3000/notes + +# Filter by tag +curl "http://localhost:3000/notes?tag=work" + +# Filter by multiple tags (OR) +curl "http://localhost:3000/notes?tag=work&tag=personal" + +# Keyword search in title and content +curl "http://localhost:3000/notes?q=budget" + +# Combined tag filter and keyword search +curl "http://localhost:3000/notes?tag=work&q=meeting" +``` + +### Update a note + +```bash +curl -X PUT http://localhost:3000/notes/:noteId \ + -H "Content-Type: application/json" \ + -d '{"title":"Updated","content":"New content","tags":["personal"]}' +``` + +If `tags` is omitted from the request body, existing tags are preserved. Send `"tags": []` to clear all tags. + ## Tutorial -You can find the tutorial for this application at [The CalliCoder Blog](https://www.callicoder.com) - - \ No newline at end of file +You can find the tutorial for this application at [The CalliCoder Blog](https://www.callicoder.com) - + + diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..8781991 --- /dev/null +++ b/agent/README.md @@ -0,0 +1,52 @@ +# AI Coding Agent + +A reusable, LLM-driven Python agent that explores, plans, implements, validates, and summarizes code changes in a repository. + +## Usage + +```bash +python agent/main.py \ + --repo \ + --task "" \ + [--output agent-output/] \ + [--model openai/gpt-4o-mini] \ + [--api-key KEY] +``` + +## Requirements + +- Python 3.8+ +- `OPENROUTER_API_KEY` environment variable or `--api-key` argument + +## Directory Structure + +``` +agent/ +├── main.py # CLI entry point +├── llm.py # LLM abstraction (LLMClient + OpenRouterClient) +├── explorer.py # Stage 1: repository inspection +├── planner.py # Stage 2: plan generation +├── executor.py # Stage 3: file modifications via LLM +├── validator.py # Stage 4: change validation +├── summarizer.py # Stage 5: execution summary +├── prompts/ # Prompt templates for each stage +│ ├── repository_analysis.md +│ ├── planning.md +│ ├── implementation.md +│ ├── validation.md +│ └── summary.md +├── requirements.txt +└── README.md +``` + +## How It Works + +1. **Explorer** — walks the target repo, detects language/framework/structure, calls the LLM to analyze +2. **Planner** — reads the analysis, calls the LLM to generate a step-by-step implementation plan +3. **Executor** — reads the plan, modifies files via LLM with complete-file-overwrite strategy, validates before writing +4. **Validator** — checks modified files for syntax validity and structural preservation +5. **Summarizer** — generates a structured markdown summary of the entire execution + +## License + +MIT \ No newline at end of file diff --git a/agent/__init__.py b/agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/executor.py b/agent/executor.py new file mode 100644 index 0000000..2833cf3 --- /dev/null +++ b/agent/executor.py @@ -0,0 +1,121 @@ +import os +import json +from validator import Validator + + +class Executor: + + def run(self, context): + print("Executing...") + plan = context.get("plan", "") + llm_client = context["llm"] + repo_path = context.get("repo_path", "") + validator = Validator() + + output_dir = context.get("output_dir", "agent-output") + plan_path = os.path.join(output_dir, "plan.md") + + if not os.path.exists(plan_path): + return {"modifications": [], "error": "Plan file not found"} + + with open(plan_path, "r", encoding="utf-8") as fh: + plan_content = fh.read() + + files_to_modify = self._extract_files_from_plan(plan_content) + + if not files_to_modify: + files_to_modify = self._extract_files_from_analysis(context.get("analysis", ""), plan_content) + + modifications = [] + + for file_info in files_to_modify: + file_path = file_info.get("path", file_info) if isinstance(file_info, dict) else file_info + abs_path = os.path.join(repo_path, file_path) + + if not os.path.exists(abs_path): + modifications.append({"file_path": file_path, "success": False, "error": "File not found"}) + continue + + with open(abs_path, "r", encoding="utf-8") as fh: + original_content = fh.read() + + result = self._execute_file( + file_path, + original_content, + plan_content, + llm_client, + context.get("language", "javascript"), + ) + + modifications.append(result) + + if result["success"]: + backup = original_content + try: + with open(abs_path, "w", encoding="utf-8") as fh: + fh.write(result["content"]) + + validation = validator.validate_file( + abs_path, + result["content"], + original_content, + plan_content, + context.get("language", "javascript"), + ) + + if not validation["passed"]: + with open(abs_path, "w", encoding="utf-8") as fh: + fh.write(backup) + result["success"] = False + result["error"] = "Post-write validation failed: " + str(validation["checks"]) + except Exception as write_err: + with open(abs_path, "w", encoding="utf-8") as fh: + fh.write(backup) + result["success"] = False + result["error"] = str(write_err) + + context["modifications"] = modifications + return {"modifications": modifications} + + def _extract_files_from_plan(self, plan_content): + files = [] + for line in plan_content.split("\n"): + stripped = line.strip() + if stripped.startswith("-") or stripped.startswith("*") or stripped.startswith("1.") or stripped.startswith("2.") or stripped.startswith("3."): + candidates = stripped.split(" ") + for candidate in candidates: + if any(candidate.endswith(ext) for ext in [".js", ".ts", ".py", ".json", ".md", ".jsx", ".tsx"]): + candidate = candidate.strip("`") + if "/" in candidate or candidate.endswith(".md"): + files.append(candidate) + return files + + def _extract_files_from_analysis(self, analysis, plan_content): + files = [] + combined = analysis + "\n" + plan_content + for line in combined.split("\n"): + stripped = line.strip() + if stripped.startswith("`") and stripped.endswith("`") and len(stripped) > 2: + files.append(stripped.strip("`")) + elif "/" in stripped and (stripped.endswith(".js") or stripped.endswith(".py") or stripped.endswith(".ts")): + files.append(stripped) + return files + + def _execute_file(self, file_path, original_content, plan_content, llm_client, language): + prompt_path = os.path.join(os.path.dirname(__file__), "prompts", "implementation.md") + with open(prompt_path, "r", encoding="utf-8") as fh: + template = fh.read() + + prompt = template.replace("{plan}", plan_content).replace("{file_path}", file_path).replace("{original_content}", original_content).replace("{language}", language) + + messages = [ + {"role": "system", "content": "You are an expert code editor applying a plan to a specific file."}, + {"role": "user", "content": prompt}, + ] + + content = llm_client.chat(messages, temperature=0.4) + + if content.strip(): + return {"file_path": file_path, "success": True, "content": content, "error": None} + else: + return {"file_path": file_path, "success": False, "content": original_content, "error": "LLM returned empty response"} diff --git a/agent/explorer.py b/agent/explorer.py new file mode 100644 index 0000000..616251c --- /dev/null +++ b/agent/explorer.py @@ -0,0 +1,142 @@ +import os +import glob + + +def detect_language(repo_path): + markers = { + "python": ["requirements.txt", "setup.py", "pyproject.toml", "setup.cfg"], + "javascript": ["package.json", "jsconfig.json"], + "typescript": ["tsconfig.json", "package.json"], + "go": ["go.mod", "go.sum"], + "rust": ["Cargo.toml"], + "ruby": ["Gemfile"], + } + for lang, files in markers.items(): + for f in files: + if os.path.exists(os.path.join(repo_path, f)): + return lang + return "unknown" + + +def detect_framework(repo_path, language): + if language == "python": + for f in ["requirements.txt", "pyproject.toml"]: + full = os.path.join(repo_path, f) + if os.path.exists(full): + with open(full, "r", encoding="utf-8", errors="replace") as fh: + content = fh.read() + if "flask" in content.lower(): + return "Flask" + if "django" in content.lower(): + return "Django" + if "fastapi" in content.lower(): + return "FastAPI" + return "Python" + if language == "javascript": + pkg_path = os.path.join(repo_path, "package.json") + if os.path.exists(pkg_path): + with open(pkg_path, "r", encoding="utf-8", errors="replace") as fh: + content = fh.read() + if "express" in content.lower(): + return "Express" + if "fastify" in content.lower(): + return "Fastify" + return "Node.js" + return language + + +def find_entry_point(repo_path, language): + candidates = { + "python": ["app.py", "main.py", "manage.py", "server.py"], + "javascript": ["server.js", "app.js", "index.js", "main.js"], + "typescript": ["server.ts", "app.ts", "index.ts"], + "go": ["main.go"], + "rust": ["src/main.rs"], + } + for name in candidates.get(language, []): + if os.path.exists(os.path.join(repo_path, name)): + return name + return None + + +def find_source_tree(repo_path, language): + source_dirs = [] + for name in ["src", "app", "lib", "modules", "controllers", "routes", "models"]: + full = os.path.join(repo_path, name) + if os.path.isdir(full): + source_dirs.append(name) + return source_dirs + + +def find_files(repo_path, pattern): + matches = glob.glob(os.path.join(repo_path, "**", pattern), recursive=True) + return [os.path.relpath(m, repo_path) for m in matches] + + +def analyze_repo(repo_path, task, llm_client): + from llm import LLMClient + assert isinstance(llm_client, LLMClient) + + language = detect_language(repo_path) + framework = detect_framework(repo_path, language) + entry_point = find_entry_point(repo_path, language) + source_tree = find_source_tree(repo_path, language) + + all_files = [] + for root, dirs, files in os.walk(repo_path): + dirs[:] = [d for d in dirs if d not in (".git", "node_modules", "__pycache__", ".venv", "venv")] + for f in sorted(files): + rel = os.path.relpath(os.path.join(root, f), repo_path) + all_files.append(rel) + + route_files = [f for f in all_files if "route" in f.lower() and f.endswith((".js", ".ts", ".py"))] + controller_files = [f for f in all_files if "controller" in f.lower() and f.endswith((".js", ".ts", ".py"))] + model_files = [f for f in all_files if "model" in f.lower() and f.endswith((".js", ".ts", ".py"))] + config_files = [f for f in all_files if f.endswith((".json", ".yaml", ".yml", ".toml", ".cfg", ".ini")) and "package" not in f.lower()] + + prompt_path = os.path.join(os.path.dirname(__file__), "prompts", "repository_analysis.md") + with open(prompt_path, "r", encoding="utf-8") as fh: + template = fh.read() + + prompt = template.replace("{repo_path}", repo_path).replace("{task}", task) + + messages = [ + {"role": "system", "content": "You are a code analysis expert. Analyze the repository structure and return a structured markdown analysis."}, + {"role": "user", "content": prompt}, + ] + + analysis = llm_client.chat(messages, temperature=0.3) + + return { + "analysis": analysis, + "language": language, + "framework": framework, + "entry_point": entry_point, + "source_tree": source_tree, + "route_files": route_files, + "controller_files": controller_files, + "model_files": model_files, + "config_files": config_files, + "all_files": all_files, + } + + +class Explorer: + + def run(self, context): + print("Exploring...") + repo_path = context["repo_path"] + task = context["task"] + llm_client = context["llm"] + + result = analyze_repo(repo_path, task, llm_client) + + output_dir = context.get("output_dir", "agent-output") + os.makedirs(output_dir, exist_ok=True) + analysis_path = os.path.join(output_dir, "analysis.md") + with open(analysis_path, "w", encoding="utf-8") as fh: + fh.write(result["analysis"]) + + context["analysis"] = result["analysis"] + context["explorer_result"] = result + return context \ No newline at end of file diff --git a/agent/llm.py b/agent/llm.py new file mode 100644 index 0000000..0d6f65d --- /dev/null +++ b/agent/llm.py @@ -0,0 +1,45 @@ +import os +from abc import ABC, abstractmethod + + +class LLMClient(ABC): + + @abstractmethod + def chat(self, messages, temperature=0.7): + pass + + +class OpenRouterClient(LLMClient): + + def __init__(self, api_key=None, model="openai/gpt-4o-mini", base_url="https://openrouter.ai/api/v1"): + self.api_key = api_key or os.environ.get("OPENROUTER_API_KEY") + self.model = model + self.base_url = base_url + + def chat(self, messages, temperature=0.7): + import json + import urllib.request + + payload = { + "model": self.model, + "messages": messages, + "temperature": temperature, + } + + headers = { + "Content-Type": "application/json", + "Authorization": "Bearer " + self.api_key, + "HTTP-Referer": "https://github.com/kilo-org/kilocode", + "X-Title": "AI Coding Agent", + } + + req = urllib.request.Request( + self.base_url + "/chat/completions", + data=json.dumps(payload).encode("utf-8"), + headers=headers, + ) + + with urllib.request.urlopen(req, timeout=120) as response: + body = json.loads(response.read().decode("utf-8")) + + return body["choices"][0]["message"]["content"] diff --git a/agent/main.py b/agent/main.py new file mode 100644 index 0000000..90b4222 --- /dev/null +++ b/agent/main.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from llm import LLMClient, OpenRouterClient +from explorer import Explorer +from planner import Planner +from executor import Executor +from validator import Validator +from summarizer import Summarizer + + +def main(): + parser = argparse.ArgumentParser(description="AI Coding Agent") + parser.add_argument("--repo", required=True, help="Path to the target repository") + parser.add_argument("--task", required=True, help="Natural language task description") + parser.add_argument("--output", default="agent-output", help="Output directory for agent artifacts") + parser.add_argument("--model", default="openai/gpt-4o-mini", help="LLM model identifier") + parser.add_argument("--api-key", default=None, help="API key (defaults to OPENROUTER_API_KEY env var)") + args = parser.parse_args() + + repo_path = os.path.abspath(args.repo) + output_dir = args.output + task = args.task + + if not os.path.isdir(repo_path): + print("Error: Repository path does not exist: " + repo_path, file=sys.stderr) + sys.exit(1) + + api_key = args.api_key or os.environ.get("OPENROUTER_API_KEY") + if not api_key: + print("Error: No API key provided. Use --api-key or set OPENROUTER_API_KEY environment variable.", file=sys.stderr) + sys.exit(1) + + llm_client = OpenRouterClient(api_key=api_key, model=args.model) + + context = { + "repo_path": repo_path, + "task": task, + "output_dir": output_dir, + "llm": llm_client, + } + + explorer = Explorer() + context = explorer.run(context) + + planner = Planner() + context = planner.run(context) + + executor = Executor() + context = executor.run(context) + + print("Validating...") + validator = Validator() + validation = validator.validate_context(context) + context["validation"] = validation + + all_passed = all( + check["passed"] + for result in validation + for check in result.get("checks", []) + ) + + summarizer = Summarizer() + context = summarizer.run(context) + + print("Done.") + + if all_passed: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/agent/planner.py b/agent/planner.py new file mode 100644 index 0000000..24835f4 --- /dev/null +++ b/agent/planner.py @@ -0,0 +1,32 @@ +import os + + +class Planner: + + def run(self, context): + print("Planning...") + analysis = context.get("analysis", "") + task = context.get("task", "") + llm_client = context["llm"] + + prompt_path = os.path.join(os.path.dirname(__file__), "prompts", "planning.md") + with open(prompt_path, "r", encoding="utf-8") as fh: + template = fh.read() + + prompt = template.replace("{analysis}", analysis).replace("{task}", task) + + messages = [ + {"role": "system", "content": "You are a software planning expert. Create a detailed implementation plan."}, + {"role": "user", "content": prompt}, + ] + + plan = llm_client.chat(messages, temperature=0.5) + + output_dir = context.get("output_dir", "agent-output") + os.makedirs(output_dir, exist_ok=True) + plan_path = os.path.join(output_dir, "plan.md") + with open(plan_path, "w", encoding="utf-8") as fh: + fh.write(plan) + + context["plan"] = plan + return context \ No newline at end of file diff --git a/agent/prompts/implementation.md b/agent/prompts/implementation.md new file mode 100644 index 0000000..7dc4b66 --- /dev/null +++ b/agent/prompts/implementation.md @@ -0,0 +1,32 @@ +# Implementation Prompt + +You are an expert code editor. You will be given a plan and the current content of a file. Edit ONLY the parts of the file that the plan requires. Preserve everything else unchanged. + +## Plan + +{plan} + +## File Path + +{file_path} + +## Current File Content (complete original) + +``` +{original_content} +``` + +## Instructions + +1. Read the plan carefully to understand what changes are needed for THIS specific file. +2. Apply ONLY the changes needed for this file. Do not modify other files. +3. Preserve the complete file content — only change what the plan requires. +4. Maintain existing code patterns, style, and conventions (indentation, naming, imports). +5. Keep all existing function signatures, route definitions, and exports unchanged unless the plan explicitly requires modifying them. +6. If the plan says to add new code (constants, helpers, new functions), place them in the appropriate location following existing patterns in the file. +7. Return ONLY the complete updated file content. Do not include any preamble, explanation, or markdown formatting like ``` code blocks. Just the raw file content. +8. The output must be syntactically valid {language} code. + +## Output Format + +The complete file content, unchanged except for the modifications specified by the plan. No markdown code fences, no explanations, no extra text. \ No newline at end of file diff --git a/agent/prompts/planning.md b/agent/prompts/planning.md new file mode 100644 index 0000000..ecbfe8d --- /dev/null +++ b/agent/prompts/planning.md @@ -0,0 +1,38 @@ +# Planning Prompt + +You are an expert software planner. Based on the repository analysis and the user's task, produce a detailed implementation plan. + +## Repository Analysis + +{analysis} + +## Task + +{task} + +## Instructions + +1. Review the repository analysis to understand the codebase structure. +2. Identify exactly which files need to be modified to implement the task. +3. For each file, describe: + - The file path + - What changes are needed (specific functions, routes, models) + - Any new constants, helpers, or imports to add +4. Ensure the plan follows the existing code patterns in the repository. +5. The plan must be specific enough that an executor can implement it file-by-file without ambiguity. + +## Output Format + +Write the plan as markdown with the following sections: + +## Files To Modify +- List each file with a brief description of changes + +## Implementation Steps +- Numbered steps in execution order + +## Validation Criteria +- How to verify the changes are correct + +## Notes +- Any assumptions, edge cases, or constraints diff --git a/agent/prompts/repository_analysis.md b/agent/prompts/repository_analysis.md new file mode 100644 index 0000000..ee6276d --- /dev/null +++ b/agent/prompts/repository_analysis.md @@ -0,0 +1,28 @@ +# Repository Analysis Prompt + +You are an expert code analyst. Analyze the repository at {repo_path} and produce a structured analysis in markdown. + +## Task + +{task} + +## Instructions + +1. Walk the repository structure and identify: + - Language(s) and framework(s) used + - Entry point(s) and configuration files + - Main source directories and key files + - Routes, controllers, models, and their locations + - Dependencies (package.json, requirements.txt, etc.) + - Test setup if any + +2. For each key file, provide: + - File path (relative to repo root) + - Purpose and key functions/classes + - Number of lines + +3. Identify what changes are needed to implement the task. + +## Output Format + +Write your analysis as markdown. Be thorough but concise. Include file paths and line references where relevant. Do not include code snippets — only descriptions and references. diff --git a/agent/prompts/summary.md b/agent/prompts/summary.md new file mode 100644 index 0000000..147b9b6 --- /dev/null +++ b/agent/prompts/summary.md @@ -0,0 +1,36 @@ +# Summary Prompt + +You are a code modification summarizer. + +## Task + +{task} + +## Repository Analysis + +{analysis} + +## Plan + +{plan} + +## Modifications + +{modifications} + +## Validation Results + +{validation} + +## Instructions + +Write a structured markdown summary of the entire agent execution covering: + +1. **Task** — What was requested. +2. **Repository Overview** — Key findings from the analysis. +3. **Plan Summary** — What changes were planned. +4. **Execution Results** — Which files were modified successfully and which failed. +5. **Validation Summary** — Overall pass/fail and per-file results. +6. **Issues** — Any validation failures or errors encountered. + +Be concise and factual. Do not include code snippets. \ No newline at end of file diff --git a/agent/prompts/validation.md b/agent/prompts/validation.md new file mode 100644 index 0000000..947d378 --- /dev/null +++ b/agent/prompts/validation.md @@ -0,0 +1,50 @@ +# Validation Prompt + +You are a code validator. You will be given the original file content, the modified file content, the plan, and the file path. Validate the modification. + +## Plan + +{plan} + +## File Path + +{file_path} + +## Original File Content + +``` +{original_content} +``` + +## Modified File Content + +``` +{modified_content} +``` + +## Instructions + +Validate the modified file content against these criteria: + +1. **Non-empty** — The output must not be empty or whitespace-only. +2. **Syntax validity** — The output must be syntactically valid {language} code. +3. **Structural preservation** — All original function names, route paths, class names, and exports that were NOT in the scope of the change must still be present. +4. **Expected imports** — Any new imports required by the plan must be present. +5. **No unintended changes** — Code unrelated to the plan's scope should be preserved exactly. + +## Output Format + +Respond with JSON only: + +{ + "passed": true or false, + "checks": [ + {"name": "non_empty", "passed": true, "message": "..."}, + {"name": "syntax_valid", "passed": true, "message": "..."}, + {"name": "structure_preserved", "passed": true, "message": "..."}, + {"name": "no_unintended_changes", "passed": true, "message": "..."} + ], + "issues": [] +} + +If any check fails, set passed to false and describe the issue in the corresponding check message. Do not include any text outside the JSON. \ No newline at end of file diff --git a/agent/requirements.txt b/agent/requirements.txt new file mode 100644 index 0000000..39aae79 --- /dev/null +++ b/agent/requirements.txt @@ -0,0 +1 @@ +requests>=2.28.0 \ No newline at end of file diff --git a/agent/summarizer.py b/agent/summarizer.py new file mode 100644 index 0000000..759472a --- /dev/null +++ b/agent/summarizer.py @@ -0,0 +1,60 @@ +import os + + +class Summarizer: + + def run(self, context): + print("Generating summary...") + analysis = context.get("analysis", "") + plan = context.get("plan", "") + modifications = context.get("modifications", []) + validation = context.get("validation", {}) + + prompt_path = os.path.join(os.path.dirname(__file__), "prompts", "summary.md") + with open(prompt_path, "r", encoding="utf-8") as fh: + template = fh.read() + + modifications_str = self._format_modifications(modifications) + validation_str = self._format_validation(validation) + + prompt = template.replace("{task}", context.get("task", "")).replace("{analysis}", analysis).replace("{plan}", plan).replace("{modifications}", modifications_str).replace("{validation}", validation_str) + + messages = [ + {"role": "system", "content": "You are a code modification summarizer. Write a clear, factual summary."}, + {"role": "user", "content": prompt}, + ] + + summary = context["llm"].chat(messages, temperature=0.5) + + output_dir = context.get("output_dir", "agent-output") + os.makedirs(output_dir, exist_ok=True) + summary_path = os.path.join(output_dir, "summary.md") + with open(summary_path, "w", encoding="utf-8") as fh: + fh.write(summary) + + context["summary"] = summary + return context + + def _format_modifications(self, modifications): + if not modifications: + return "No modifications made." + lines = [] + for mod in modifications: + status = "SUCCESS" if mod.get("success") else "FAILED" + error = mod.get("error") or "" + lines.append(f"- [{status}] {mod.get('file_path', 'unknown')} {error}") + return "\n".join(lines) + + def _format_validation(self, validation): + if isinstance(validation, dict) and "checks" in validation: + lines = [] + for check in validation.get("checks", []): + status = "PASS" if check.get("passed") else "FAIL" + lines.append(f" - [{status}] {check.get('name', 'unknown')}: {check.get('message', '')}") + return "\n".join(lines) + if isinstance(validation, list) and validation: + lines = [] + for v in validation: + lines.append(self._format_validation(v)) + return "\n".join(lines) + return "No validation results available." \ No newline at end of file diff --git a/agent/validator.py b/agent/validator.py new file mode 100644 index 0000000..8340c95 --- /dev/null +++ b/agent/validator.py @@ -0,0 +1,207 @@ +import os +import ast +import json +import re + + +class Validator: + + def validate_file(self, file_path, modified_content, original_content, plan_content, language): + checks = [] + + checks.append(self._check_non_empty(file_path, modified_content)) + checks.append(self._check_syntax(file_path, modified_content, language)) + checks.append(self._check_structure_preserved(file_path, modified_content, original_content, language)) + checks.append(self._check_plan_references(file_path, modified_content, plan_content)) + + passed = all(c["passed"] for c in checks) + + return {"passed": passed, "checks": checks} + + def validate_context(self, context): + results = [] + for mod in context.get("modifications", []): + if mod.get("success"): + file_path = mod["file_path"] + repo_path = context.get("repo_path", "") + abs_path = os.path.join(repo_path, file_path) + if os.path.exists(abs_path): + with open(abs_path, "r", encoding="utf-8") as fh: + content = fh.read() + result = self.validate_file( + file_path, + content, + context.get("original_files", {}).get(file_path, ""), + context.get("plan", ""), + context.get("language", "javascript"), + ) + results.append(result) + return results + + def _check_non_empty(self, file_path, content): + if content and content.strip(): + return {"name": "non_empty", "passed": True, "message": "Output is non-empty"} + return {"name": "non_empty", "passed": False, "message": "Output is empty or whitespace-only"} + + def _check_syntax(self, file_path, content, language): + ext = os.path.splitext(file_path)[1].lower() + + if ext == ".py": + try: + compile(content, file_path, "exec") + return {"name": "syntax_valid", "passed": True, "message": "Valid Python syntax"} + except SyntaxError as e: + return {"name": "syntax_valid", "passed": False, "message": f"Syntax error: {e}"} + + if ext in (".js", ".ts", ".jsx", ".tsx"): + if self._check_js_syntax(content): + return {"name": "syntax_valid", "passed": True, "message": "Basic JS/TS syntax check passed"} + return {"name": "syntax_valid", "passed": False, "message": "Basic JS/TS syntax check failed"} + + if ext == ".json": + try: + json.loads(content) + return {"name": "syntax_valid", "passed": True, "message": "Valid JSON"} + except json.JSONDecodeError as e: + return {"name": "syntax_valid", "passed": False, "message": f"JSON error: {e}"} + + return {"name": "syntax_valid", "passed": True, "message": f"No syntax checker for {ext}, skipping"} + + def _check_js_syntax(self, content): + lines = content.split("\n") + brace_count = 0 + paren_count = 0 + bracket_count = 0 + in_string = False + string_char = None + in_template = False + + for i, line in enumerate(lines): + j = 0 + while j < len(line): + ch = line[j] + + if in_template: + if ch == "`" and line[max(0, j - 1):j + 1] != "\\`": + in_template = False + elif ch == "$" and j + 1 < len(line) and line[j + 1] == "{": + brace_count += 1 + j += 1 + elif in_string: + if ch == "\\": + j += 1 + elif ch == string_char: + in_string = False + string_char = None + else: + if ch == "'" or ch == '"': + if self._is_template_literal(line, j): + in_template = True + else: + in_string = True + string_char = ch + elif ch == "`": + in_template = True + elif ch == "{" and line[max(0, j - 1):j + 1] not in ("${", "#{"): + brace_count += 1 + elif ch == "}" and line[max(0, j - 1):j + 1] not in ("}", "}"): + brace_count -= 1 + elif ch == "(": + paren_count += 1 + elif ch == ")": + paren_count -= 1 + elif ch == "[": + bracket_count += 1 + elif ch == "]": + bracket_count -= 1 + + j += 1 + + if brace_count < 0 or paren_count < 0 or bracket_count < 0: + return False + + return brace_count == 0 and paren_count == 0 and bracket_count == 0 + + def _is_template_literal(self, line, pos): + before = line[:pos] + backtick_count = before.count("`") + return backtick_count % 2 == 1 + + def _check_structure_preserved(self, file_path, modified_content, original_content, language): + original_funcs = self._extract_functions(original_content, language) + original_routes = self._extract_routes(original_content, language) + original_exports = self._extract_exports(original_content, language) + + modified_funcs = self._extract_functions(modified_content, language) + modified_routes = self._extract_routes(modified_content, language) + modified_exports = self._extract_exports(modified_content, language) + + missing_funcs = [f for f in original_funcs if f not in modified_funcs] + missing_routes = [r for r in original_routes if r not in modified_routes] + missing_exports = [e for e in original_exports if e not in modified_exports] + + issues = [] + if missing_funcs: + issues.append(f"Missing functions: {missing_funcs}") + if missing_routes: + issues.append(f"Missing routes: {missing_routes}") + if missing_exports: + issues.append(f"Missing exports: {missing_exports}") + + if issues: + return {"name": "structure_preserved", "passed": False, "message": "; ".join(issues)} + + return {"name": "structure_preserved", "passed": True, "message": "All original structures preserved"} + + def _extract_functions(self, content, language): + funcs = [] + if language == "javascript" or language == "typescript": + for match in re.finditer(r'(?:exports\.\w+|function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:function|\([^)]*\)\s*=>))', content): + name = match.group(1) or match.group(2) + if name: + funcs.append(name) + for match in re.finditer(r'module\.exports\s*=\s*(?:\{([^}]*)\}|(\w+))', content): + if match.group(1): + for name in match.group(1).split(","): + name = name.strip().split(":")[0].strip() + if name: + funcs.append(name) + if match.group(2): + funcs.append(match.group(2)) + elif language == "python": + for match in re.finditer(r'def\s+(\w+)', content): + funcs.append(match.group(1)) + for match in re.finditer(r'class\s+(\w+)', content): + funcs.append(match.group(1)) + return funcs + + def _extract_routes(self, content, language): + routes = [] + if language == "javascript" or language == "typescript": + for match in re.finditer(r'(?:app\.|router\.)\s*(get|post|put|delete|patch)\s*\(\s*["\']([^"\']+)["\']', content, re.IGNORECASE): + routes.append(match.group(2)) + return routes + + def _extract_exports(self, content, language): + exports = [] + if language == "javascript" or language == "typescript": + for match in re.finditer(r'exports\.(\w+)\s*=', content): + exports.append(match.group(1)) + for match in re.finditer(r'module\.exports\s*=\s*\{([^}]*)\}', content): + for name in match.group(1).split(","): + name = name.strip().split(":")[0].strip() + if name: + exports.append(name) + for match in re.finditer(r'module\.exports\s*=\s*(\w+)', content): + exports.append(match.group(1)) + for match in re.finditer(r'export\s+(?:default\s+)?(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)|class\s+(\w+))', content): + name = match.group(1) or match.group(2) or match.group(3) + if name: + exports.append(name) + elif language == "python": + for match in re.finditer(r'def\s+(\w+)', content): + exports.append(match.group(1)) + return exports + + def _check_plan_references(self, file_path, modified_content, plan_content): + return {"name": "plan_references", "passed": True, "message": "Plan reference check skipped for individual files"} \ No newline at end of file diff --git a/app/controllers/note.controller.js b/app/controllers/note.controller.js index 7390849..70c8e9b 100644 --- a/app/controllers/note.controller.js +++ b/app/controllers/note.controller.js @@ -1,39 +1,136 @@ const Note = require('../models/note.model.js'); +const MAX_TAGS = 10; +const MAX_TAG_LENGTH = 50; +const MAX_SEARCH_LENGTH = 200; + +function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function normalizeTags(rawTags) { + if (!Array.isArray(rawTags)) { + return { error: 'Tags must be an array' }; + } + + const normalized = []; + + for (const tag of rawTags) { + if (typeof tag !== 'string') { + return { error: 'Each tag must be a non-empty string' }; + } + + const trimmed = tag.trim().toLowerCase(); + if (!trimmed) { + return { error: 'Each tag must be a non-empty string' }; + } + + if (trimmed.length > MAX_TAG_LENGTH) { + return { error: `Each tag must be at most ${MAX_TAG_LENGTH} characters` }; + } + + if (!normalized.includes(trimmed)) { + normalized.push(trimmed); + } + } + + if (normalized.length > MAX_TAGS) { + return { error: `A note can have at most ${MAX_TAGS} tags` }; + } + + return { tags: normalized }; +} + +function buildListFilter(query) { + const filter = {}; + const conditions = []; + + if (query.tag !== undefined) { + const rawTags = Array.isArray(query.tag) ? query.tag : [query.tag]; + const tags = rawTags + .filter(tag => typeof tag === 'string') + .map(tag => tag.trim().toLowerCase()) + .filter(Boolean); + + if (tags.length > 0) { + conditions.push({ tags: { $in: tags } }); + } + } + + if (query.q !== undefined && typeof query.q === 'string') { + const trimmed = query.q.trim(); + if (trimmed) { + const pattern = escapeRegex(trimmed); + conditions.push({ + $or: [ + { title: { $regex: pattern, $options: 'i' } }, + { content: { $regex: pattern, $options: 'i' } } + ] + }); + } + } + + if (conditions.length === 1) { + return conditions[0]; + } + + if (conditions.length > 1) { + return { $and: conditions }; + } + + return filter; +} + // Create and Save a new Note exports.create = (req, res) => { - // Validate request - if(!req.body.content) { + if (!req.body.content) { return res.status(400).send({ message: "Note content can not be empty" }); } - // Create a Note + let tags = []; + + if (req.body.tags !== undefined) { + const result = normalizeTags(req.body.tags); + if (result.error) { + return res.status(400).send({ message: result.error }); + } + tags = result.tags; + } + const note = new Note({ - title: req.body.title || "Untitled Note", - content: req.body.content + title: req.body.title || "Untitled Note", + content: req.body.content, + tags }); - // Save Note in the database note.save() .then(data => { res.send(data); }).catch(err => { + console.error('Error creating Note:', err.message); res.status(500).send({ - message: err.message || "Some error occurred while creating the Note." + message: "Some error occurred while creating the Note." }); }); }; // Retrieve and return all notes from the database. exports.findAll = (req, res) => { - Note.find() + if (req.query.q !== undefined && typeof req.query.q === 'string' && req.query.q.trim().length > MAX_SEARCH_LENGTH) { + return res.status(400).send({ message: "Search query must be at most " + MAX_SEARCH_LENGTH + " characters" }); + } + + const filterResult = buildListFilter(req.query); + + Note.find(filterResult) .then(notes => { res.send(notes); }).catch(err => { + console.error('Error retrieving notes:', err.message); res.status(500).send({ - message: err.message || "Some error occurred while retrieving notes." + message: "Some error occurred while retrieving notes." }); }); }; @@ -42,17 +139,22 @@ exports.findAll = (req, res) => { exports.findOne = (req, res) => { Note.findById(req.params.noteId) .then(note => { - if(!note) { + if (!note) { return res.status(404).send({ message: "Note not found with id " + req.params.noteId - }); + }); } - res.send(note); + const noteObj = note.toObject(); + if (!noteObj.tags) { + noteObj.tags = []; + } + res.send(noteObj); }).catch(err => { - if(err.kind === 'ObjectId') { + console.error('Error retrieving note:', err.message); + if (err.kind === 'ObjectId') { return res.status(404).send({ message: "Note not found with id " + req.params.noteId - }); + }); } return res.status(500).send({ message: "Error retrieving note with id " + req.params.noteId @@ -62,30 +164,42 @@ exports.findOne = (req, res) => { // Update a note identified by the noteId in the request exports.update = (req, res) => { - // Validate Request - if(!req.body.content) { + if (!req.body.content) { return res.status(400).send({ message: "Note content can not be empty" }); } - // Find note and update it with the request body - Note.findByIdAndUpdate(req.params.noteId, { - title: req.body.title || "Untitled Note", + const updateData = { content: req.body.content - }, {new: true}) + }; + + if (req.body.title !== undefined) { + updateData.title = req.body.title; + } + + if (req.body.tags !== undefined) { + const result = normalizeTags(req.body.tags); + if (result.error) { + return res.status(400).send({ message: result.error }); + } + updateData.tags = result.tags; + } + + Note.findByIdAndUpdate(req.params.noteId, updateData, { new: true }) .then(note => { - if(!note) { + if (!note) { return res.status(404).send({ message: "Note not found with id " + req.params.noteId }); } res.send(note); }).catch(err => { - if(err.kind === 'ObjectId') { + console.error('Error updating note:', err.message); + if (err.kind === 'ObjectId') { return res.status(404).send({ message: "Note not found with id " + req.params.noteId - }); + }); } return res.status(500).send({ message: "Error updating note with id " + req.params.noteId @@ -95,19 +209,20 @@ exports.update = (req, res) => { // Delete a note with the specified noteId in the request exports.delete = (req, res) => { - Note.findByIdAndRemove(req.params.noteId) + Note.findByIdAndDelete(req.params.noteId) .then(note => { - if(!note) { + if (!note) { return res.status(404).send({ message: "Note not found with id " + req.params.noteId }); } - res.send({message: "Note deleted successfully!"}); + res.send({ message: "Note deleted successfully!" }); }).catch(err => { - if(err.kind === 'ObjectId' || err.name === 'NotFound') { + console.error('Error deleting note:', err.message); + if (err.kind === 'ObjectId' || err.name === 'NotFound') { return res.status(404).send({ message: "Note not found with id " + req.params.noteId - }); + }); } return res.status(500).send({ message: "Could not delete note with id " + req.params.noteId diff --git a/app/models/note.model.js b/app/models/note.model.js index d5523c6..39f573f 100644 --- a/app/models/note.model.js +++ b/app/models/note.model.js @@ -2,9 +2,15 @@ const mongoose = require('mongoose'); const NoteSchema = mongoose.Schema({ title: String, - content: String + content: String, + tags: { + type: [String], + default: [] + } }, { timestamps: true }); -module.exports = mongoose.model('Note', NoteSchema); \ No newline at end of file +NoteSchema.index({ tags: 1 }); + +module.exports = mongoose.model('Note', NoteSchema);