mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
feat(ipuaro): implement v0.1.0 foundation
- Project setup with tsup, vitest, ESM support - Domain entities: Session, Project - Value objects: FileData, FileAST, FileMeta, ChatMessage, ToolCall, ToolResult, UndoEntry - Service interfaces: IStorage, ILLMClient, ITool, IIndexer, IToolRegistry - Shared: Config (zod), IpuaroError, utils (hash, tokens), Result type - CLI with placeholder commands (start, init, index) - 91 unit tests with 100% coverage - Fix package scope @puaros -> @samiyev in CLAUDE.md
This commit is contained in:
12
CLAUDE.md
12
CLAUDE.md
@@ -6,9 +6,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
Puaros is a TypeScript monorepo using pnpm workspaces. Contains two packages:
|
Puaros is a TypeScript monorepo using pnpm workspaces. Contains two packages:
|
||||||
|
|
||||||
- **`@puaros/guardian`** - Code quality guardian for detecting hardcoded values, circular dependencies, framework leaks, naming violations, and architecture violations.
|
- **`@samiyev/guardian`** - Code quality guardian for detecting hardcoded values, circular dependencies, framework leaks, naming violations, and architecture violations.
|
||||||
|
|
||||||
- **`@puaros/ipuaro`** - Local AI agent for codebase operations with "infinite" context feeling. Uses lazy loading, Redis persistence, tree-sitter AST parsing, and Ollama LLM integration.
|
- **`@samiyev/ipuaro`** - Local AI agent for codebase operations with "infinite" context feeling. Uses lazy loading, Redis persistence, tree-sitter AST parsing, and Ollama LLM integration.
|
||||||
|
|
||||||
The project uses Node.js 22.18.0 (see `.nvmrc`).
|
The project uses Node.js 22.18.0 (see `.nvmrc`).
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ Examples:
|
|||||||
```
|
```
|
||||||
puaros/
|
puaros/
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── guardian/ # @puaros/guardian - Code quality analyzer
|
│ ├── guardian/ # @samiyev/guardian - Code quality analyzer
|
||||||
│ │ ├── src/ # Source files (Clean Architecture)
|
│ │ ├── src/ # Source files (Clean Architecture)
|
||||||
│ │ │ ├── domain/ # Entities, value objects
|
│ │ │ ├── domain/ # Entities, value objects
|
||||||
│ │ │ ├── application/ # Use cases, DTOs
|
│ │ │ ├── application/ # Use cases, DTOs
|
||||||
@@ -180,7 +180,7 @@ puaros/
|
|||||||
│ │ ├── bin/ # CLI entry point
|
│ │ ├── bin/ # CLI entry point
|
||||||
│ │ ├── tests/ # Test files
|
│ │ ├── tests/ # Test files
|
||||||
│ │ └── examples/ # Usage examples
|
│ │ └── examples/ # Usage examples
|
||||||
│ └── ipuaro/ # @puaros/ipuaro - Local AI agent
|
│ └── ipuaro/ # @samiyev/ipuaro - Local AI agent
|
||||||
│ ├── src/ # Source files (Clean Architecture)
|
│ ├── src/ # Source files (Clean Architecture)
|
||||||
│ │ ├── domain/ # Entities, value objects, services
|
│ │ ├── domain/ # Entities, value objects, services
|
||||||
│ │ ├── application/ # Use cases, DTOs, mappers
|
│ │ ├── application/ # Use cases, DTOs, mappers
|
||||||
@@ -260,7 +260,7 @@ Guardian package (`packages/guardian/tsconfig.json`):
|
|||||||
## Adding New Packages
|
## Adding New Packages
|
||||||
|
|
||||||
1. Create `packages/new-package/` directory
|
1. Create `packages/new-package/` directory
|
||||||
2. Add `package.json` with name `@puaros/new-package`
|
2. Add `package.json` with name `@samiyev/new-package`
|
||||||
3. Create `tsconfig.json` extending `../../tsconfig.base.json`
|
3. Create `tsconfig.json` extending `../../tsconfig.base.json`
|
||||||
4. Package auto-discovered via `pnpm-workspace.yaml` glob pattern
|
4. Package auto-discovered via `pnpm-workspace.yaml` glob pattern
|
||||||
|
|
||||||
@@ -412,7 +412,7 @@ npm pack --dry-run
|
|||||||
npm publish --access public
|
npm publish --access public
|
||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
npm info @puaros/<package>
|
npm info @samiyev/<package>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pipeline Checklist
|
## Pipeline Checklist
|
||||||
|
|||||||
51
packages/ipuaro/CHANGELOG.md
Normal file
51
packages/ipuaro/CHANGELOG.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.1.0] - 2025-01-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Project Setup**
|
||||||
|
- package.json with all dependencies (ink, ioredis, tree-sitter, ollama, etc.)
|
||||||
|
- tsconfig.json for ESM + React JSX
|
||||||
|
- tsup.config.ts for bundling
|
||||||
|
- vitest.config.ts with 80% coverage threshold
|
||||||
|
- CLI entry point (bin/ipuaro.js)
|
||||||
|
|
||||||
|
- **Domain Layer**
|
||||||
|
- Entities: Session, Project
|
||||||
|
- Value Objects: FileData, FileAST, FileMeta, ChatMessage, ToolCall, ToolResult, UndoEntry
|
||||||
|
- Service Interfaces: IStorage, ILLMClient, ITool, IIndexer
|
||||||
|
- Constants: supported extensions, ignore patterns, context limits
|
||||||
|
|
||||||
|
- **Application Layer**
|
||||||
|
- IToolRegistry interface
|
||||||
|
- Placeholder structure for use cases and DTOs
|
||||||
|
|
||||||
|
- **Shared Module**
|
||||||
|
- Config schema with Zod validation
|
||||||
|
- Config loader (default.json + .ipuaro.json)
|
||||||
|
- IpuaroError class with typed errors
|
||||||
|
- Utility functions: md5 hash, token estimation
|
||||||
|
- Result type for error handling
|
||||||
|
|
||||||
|
- **CLI**
|
||||||
|
- Basic commands: start, init, index (placeholders)
|
||||||
|
- Commander.js integration
|
||||||
|
|
||||||
|
- **Testing**
|
||||||
|
- 91 unit tests
|
||||||
|
- 100% code coverage
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
This is the foundation release. The following features are planned for upcoming versions:
|
||||||
|
- 0.2.0: Redis Storage
|
||||||
|
- 0.3.0: Indexer
|
||||||
|
- 0.4.0: LLM Integration
|
||||||
|
- 0.5.0+: Tools implementation
|
||||||
|
- 0.10.0+: TUI and session management
|
||||||
130
packages/ipuaro/README.md
Normal file
130
packages/ipuaro/README.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# @samiyev/ipuaro
|
||||||
|
|
||||||
|
Local AI agent for codebase operations with "infinite" context feeling through lazy loading.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 18 LLM tools for code operations (read, edit, search, analysis, git, run)
|
||||||
|
- Redis persistence with AOF for durability
|
||||||
|
- tree-sitter AST parsing (TypeScript, JavaScript)
|
||||||
|
- Ollama LLM integration (local, private)
|
||||||
|
- File watching for live index updates
|
||||||
|
- Session and undo management
|
||||||
|
- Security (blacklist/whitelist for shell commands)
|
||||||
|
- Terminal UI with Ink/React
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @samiyev/ipuaro
|
||||||
|
# or
|
||||||
|
pnpm add @samiyev/ipuaro
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js >= 20.0.0
|
||||||
|
- Redis server (for persistence)
|
||||||
|
- Ollama (for LLM inference)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start in current directory
|
||||||
|
ipuaro
|
||||||
|
|
||||||
|
# Start in specific directory
|
||||||
|
ipuaro /path/to/project
|
||||||
|
|
||||||
|
# With auto-apply mode
|
||||||
|
ipuaro --auto-apply
|
||||||
|
|
||||||
|
# With custom model
|
||||||
|
ipuaro --model qwen2.5-coder:32b-instruct
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `ipuaro [path]` | Start TUI in directory |
|
||||||
|
| `ipuaro init` | Create .ipuaro.json config |
|
||||||
|
| `ipuaro index` | Index project without TUI |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create `.ipuaro.json` in your project root:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"redis": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 6379
|
||||||
|
},
|
||||||
|
"llm": {
|
||||||
|
"model": "qwen2.5-coder:7b-instruct",
|
||||||
|
"temperature": 0.1
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"autoApply": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Clean Architecture with clear separation:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── domain/ # Business logic (entities, value objects, interfaces)
|
||||||
|
├── application/ # Use cases, DTOs, orchestration
|
||||||
|
├── infrastructure/ # External implementations (Redis, Ollama, tools)
|
||||||
|
├── tui/ # Terminal UI (Ink/React components)
|
||||||
|
├── cli/ # CLI commands
|
||||||
|
└── shared/ # Cross-cutting concerns
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools (18 total)
|
||||||
|
|
||||||
|
| Category | Tool | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| **Read** | `get_lines` | Get file lines |
|
||||||
|
| | `get_function` | Get function by name |
|
||||||
|
| | `get_class` | Get class by name |
|
||||||
|
| | `get_structure` | Get project tree |
|
||||||
|
| **Edit** | `edit_lines` | Replace lines |
|
||||||
|
| | `create_file` | Create new file |
|
||||||
|
| | `delete_file` | Delete file |
|
||||||
|
| **Search** | `find_references` | Find symbol usages |
|
||||||
|
| | `find_definition` | Find symbol definition |
|
||||||
|
| **Analysis** | `get_dependencies` | File imports |
|
||||||
|
| | `get_dependents` | Files importing this |
|
||||||
|
| | `get_complexity` | Complexity metrics |
|
||||||
|
| | `get_todos` | Find TODO/FIXME |
|
||||||
|
| **Git** | `git_status` | Repository status |
|
||||||
|
| | `git_diff` | Uncommitted changes |
|
||||||
|
| | `git_commit` | Create commit |
|
||||||
|
| **Run** | `run_command` | Execute shell command |
|
||||||
|
| | `run_tests` | Run test suite |
|
||||||
|
|
||||||
|
## Development Status
|
||||||
|
|
||||||
|
Currently at version **0.1.0** (Foundation). See [ROADMAP.md](./ROADMAP.md) for full development plan.
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
|
||||||
|
- [x] 0.1.1 Project Setup
|
||||||
|
- [x] 0.1.2 Domain Value Objects
|
||||||
|
- [x] 0.1.3 Domain Services Interfaces
|
||||||
|
- [x] 0.1.4 Shared Config
|
||||||
|
|
||||||
|
### Next
|
||||||
|
|
||||||
|
- [ ] 0.2.0 Redis Storage
|
||||||
|
- [ ] 0.3.0 Indexer
|
||||||
|
- [ ] 0.4.0 LLM Integration
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
54
packages/ipuaro/TODO.md
Normal file
54
packages/ipuaro/TODO.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# ipuaro TODO
|
||||||
|
|
||||||
|
## In Progress
|
||||||
|
|
||||||
|
### Version 0.2.0 - Redis Storage
|
||||||
|
- [ ] RedisClient with AOF config
|
||||||
|
- [ ] Redis schema implementation
|
||||||
|
- [ ] RedisStorage class
|
||||||
|
|
||||||
|
## Planned
|
||||||
|
|
||||||
|
### Version 0.3.0 - Indexer
|
||||||
|
- [ ] FileScanner with gitignore support
|
||||||
|
- [ ] ASTParser with tree-sitter
|
||||||
|
- [ ] MetaAnalyzer for complexity
|
||||||
|
- [ ] IndexBuilder for symbols
|
||||||
|
- [ ] Watchdog for file changes
|
||||||
|
|
||||||
|
### Version 0.4.0 - LLM Integration
|
||||||
|
- [ ] OllamaClient implementation
|
||||||
|
- [ ] System prompt design
|
||||||
|
- [ ] Tool definitions (XML format)
|
||||||
|
- [ ] Response parser
|
||||||
|
|
||||||
|
### Version 0.5.0+ - Tools
|
||||||
|
- [ ] Read tools (get_lines, get_function, get_class, get_structure)
|
||||||
|
- [ ] Edit tools (edit_lines, create_file, delete_file)
|
||||||
|
- [ ] Search tools (find_references, find_definition)
|
||||||
|
- [ ] Analysis tools (get_dependencies, get_dependents, get_complexity, get_todos)
|
||||||
|
- [ ] Git tools (git_status, git_diff, git_commit)
|
||||||
|
- [ ] Run tools (run_command, run_tests)
|
||||||
|
|
||||||
|
### Version 0.10.0+ - Session & TUI
|
||||||
|
- [ ] Session management
|
||||||
|
- [ ] Context compression
|
||||||
|
- [ ] TUI components (StatusBar, Chat, Input, DiffView)
|
||||||
|
- [ ] Slash commands (/help, /clear, /undo, etc.)
|
||||||
|
|
||||||
|
## Technical Debt
|
||||||
|
|
||||||
|
_None at this time._
|
||||||
|
|
||||||
|
## Ideas for Future
|
||||||
|
|
||||||
|
- Plugin system for custom tools
|
||||||
|
- Multiple LLM providers (OpenAI, Anthropic)
|
||||||
|
- IDE integration (LSP)
|
||||||
|
- Web UI option
|
||||||
|
- Parallel AST parsing
|
||||||
|
- Response caching
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2025-01-29
|
||||||
3
packages/ipuaro/bin/ipuaro.js
Normal file
3
packages/ipuaro/bin/ipuaro.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import "../dist/cli/index.js"
|
||||||
80
packages/ipuaro/package.json
Normal file
80
packages/ipuaro/package.json
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"name": "@samiyev/ipuaro",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Local AI agent for codebase operations with infinite context feeling",
|
||||||
|
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"bin": {
|
||||||
|
"ipuaro": "./bin/ipuaro.js"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"bin"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"watch": "tsup --watch",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:watch": "vitest --watch",
|
||||||
|
"lint": "eslint src --fix",
|
||||||
|
"format": "prettier --write src"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ink": "^4.4.1",
|
||||||
|
"ink-text-input": "^5.0.1",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"ioredis": "^5.4.1",
|
||||||
|
"tree-sitter": "^0.21.1",
|
||||||
|
"tree-sitter-typescript": "^0.21.2",
|
||||||
|
"tree-sitter-javascript": "^0.21.0",
|
||||||
|
"ollama": "^0.5.11",
|
||||||
|
"simple-git": "^3.27.0",
|
||||||
|
"chokidar": "^3.6.0",
|
||||||
|
"commander": "^11.1.0",
|
||||||
|
"zod": "^3.23.8",
|
||||||
|
"ignore": "^5.3.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.1",
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"vitest": "^1.6.0",
|
||||||
|
"@vitest/coverage-v8": "^1.6.0",
|
||||||
|
"@vitest/ui": "^1.6.0",
|
||||||
|
"tsup": "^8.3.5",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ai",
|
||||||
|
"agent",
|
||||||
|
"codebase",
|
||||||
|
"llm",
|
||||||
|
"ollama",
|
||||||
|
"cli",
|
||||||
|
"terminal"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/samiyev/puaros.git",
|
||||||
|
"directory": "packages/ipuaro"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/samiyev/puaros/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/samiyev/puaros/tree/main/packages/ipuaro#readme"
|
||||||
|
}
|
||||||
4
packages/ipuaro/src/application/dtos/index.ts
Normal file
4
packages/ipuaro/src/application/dtos/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/*
|
||||||
|
* Application DTOs
|
||||||
|
* Will be implemented in version 0.10.0+
|
||||||
|
*/
|
||||||
10
packages/ipuaro/src/application/index.ts
Normal file
10
packages/ipuaro/src/application/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// Application Layer exports
|
||||||
|
|
||||||
|
// Use Cases
|
||||||
|
export * from "./use-cases/index.js"
|
||||||
|
|
||||||
|
// DTOs
|
||||||
|
export * from "./dtos/index.js"
|
||||||
|
|
||||||
|
// Interfaces
|
||||||
|
export * from "./interfaces/index.js"
|
||||||
51
packages/ipuaro/src/application/interfaces/IToolRegistry.ts
Normal file
51
packages/ipuaro/src/application/interfaces/IToolRegistry.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { ITool, ToolContext } from "../../domain/services/ITool.js"
|
||||||
|
import type { ToolResult } from "../../domain/value-objects/ToolResult.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool registry interface.
|
||||||
|
* Manages registration and execution of tools.
|
||||||
|
*/
|
||||||
|
export interface IToolRegistry {
|
||||||
|
/**
|
||||||
|
* Register a tool.
|
||||||
|
*/
|
||||||
|
register(tool: ITool): void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tool by name.
|
||||||
|
*/
|
||||||
|
get(name: string): ITool | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered tools.
|
||||||
|
*/
|
||||||
|
getAll(): ITool[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tools by category.
|
||||||
|
*/
|
||||||
|
getByCategory(category: ITool["category"]): ITool[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if tool exists.
|
||||||
|
*/
|
||||||
|
has(name: string): boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute tool by name.
|
||||||
|
*/
|
||||||
|
execute(name: string, params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tool definitions for LLM.
|
||||||
|
*/
|
||||||
|
getToolDefinitions(): {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
parameters: {
|
||||||
|
type: "object"
|
||||||
|
properties: Record<string, { type: string; description: string }>
|
||||||
|
required: string[]
|
||||||
|
}
|
||||||
|
}[]
|
||||||
|
}
|
||||||
2
packages/ipuaro/src/application/interfaces/index.ts
Normal file
2
packages/ipuaro/src/application/interfaces/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Application Interfaces
|
||||||
|
export * from "./IToolRegistry.js"
|
||||||
4
packages/ipuaro/src/application/use-cases/index.ts
Normal file
4
packages/ipuaro/src/application/use-cases/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/*
|
||||||
|
* Application Use Cases
|
||||||
|
* Will be implemented in version 0.10.0+
|
||||||
|
*/
|
||||||
44
packages/ipuaro/src/cli/index.ts
Normal file
44
packages/ipuaro/src/cli/index.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { Command } from "commander"
|
||||||
|
|
||||||
|
const program = new Command()
|
||||||
|
|
||||||
|
program
|
||||||
|
.name("ipuaro")
|
||||||
|
.description("Local AI agent for codebase operations with infinite context feeling")
|
||||||
|
.version("0.1.0")
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("start")
|
||||||
|
.description("Start ipuaro TUI in the current directory")
|
||||||
|
.argument("[path]", "Project path", ".")
|
||||||
|
.option("--auto-apply", "Enable auto-apply mode for edits")
|
||||||
|
.option("--model <name>", "Override LLM model", "qwen2.5-coder:7b-instruct")
|
||||||
|
.action((path: string, options: { autoApply?: boolean; model?: string }) => {
|
||||||
|
const model = options.model ?? "default"
|
||||||
|
const autoApply = options.autoApply ?? false
|
||||||
|
console.warn(`Starting ipuaro in ${path}...`)
|
||||||
|
console.warn(`Model: ${model}`)
|
||||||
|
console.warn(`Auto-apply: ${autoApply ? "enabled" : "disabled"}`)
|
||||||
|
console.warn("\nNot implemented yet. Coming in version 0.11.0!")
|
||||||
|
})
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("init")
|
||||||
|
.description("Create .ipuaro.json config file")
|
||||||
|
.action(() => {
|
||||||
|
console.warn("Creating .ipuaro.json...")
|
||||||
|
console.warn("\nNot implemented yet. Coming in version 0.17.0!")
|
||||||
|
})
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("index")
|
||||||
|
.description("Index project without starting TUI")
|
||||||
|
.argument("[path]", "Project path", ".")
|
||||||
|
.action((path: string) => {
|
||||||
|
console.warn(`Indexing ${path}...`)
|
||||||
|
console.warn("\nNot implemented yet. Coming in version 0.3.0!")
|
||||||
|
})
|
||||||
|
|
||||||
|
program.parse()
|
||||||
48
packages/ipuaro/src/domain/constants/index.ts
Normal file
48
packages/ipuaro/src/domain/constants/index.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Domain Constants
|
||||||
|
|
||||||
|
export const MAX_UNDO_STACK_SIZE = 10
|
||||||
|
|
||||||
|
export const SUPPORTED_EXTENSIONS = [
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".js",
|
||||||
|
".jsx",
|
||||||
|
".json",
|
||||||
|
".yaml",
|
||||||
|
".yml",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const BINARY_EXTENSIONS = [
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".gif",
|
||||||
|
".ico",
|
||||||
|
".svg",
|
||||||
|
".woff",
|
||||||
|
".woff2",
|
||||||
|
".ttf",
|
||||||
|
".eot",
|
||||||
|
".mp3",
|
||||||
|
".mp4",
|
||||||
|
".webm",
|
||||||
|
".pdf",
|
||||||
|
".zip",
|
||||||
|
".tar",
|
||||||
|
".gz",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const DEFAULT_IGNORE_PATTERNS = [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
".git",
|
||||||
|
".next",
|
||||||
|
".nuxt",
|
||||||
|
"coverage",
|
||||||
|
".cache",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const CONTEXT_WINDOW_SIZE = 128_000
|
||||||
|
|
||||||
|
export const CONTEXT_COMPRESSION_THRESHOLD = 0.8
|
||||||
61
packages/ipuaro/src/domain/entities/Project.ts
Normal file
61
packages/ipuaro/src/domain/entities/Project.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { basename, dirname } from "node:path"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project entity representing an indexed codebase.
|
||||||
|
*/
|
||||||
|
export class Project {
|
||||||
|
readonly name: string
|
||||||
|
readonly rootPath: string
|
||||||
|
readonly createdAt: number
|
||||||
|
lastIndexedAt: number | null
|
||||||
|
fileCount: number
|
||||||
|
indexingInProgress: boolean
|
||||||
|
|
||||||
|
constructor(rootPath: string, createdAt?: number) {
|
||||||
|
this.rootPath = rootPath
|
||||||
|
this.name = Project.generateProjectName(rootPath)
|
||||||
|
this.createdAt = createdAt ?? Date.now()
|
||||||
|
this.lastIndexedAt = null
|
||||||
|
this.fileCount = 0
|
||||||
|
this.indexingInProgress = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate project name from path.
|
||||||
|
* Format: {parent-folder}-{project-folder}
|
||||||
|
*/
|
||||||
|
static generateProjectName(rootPath: string): string {
|
||||||
|
const projectFolder = basename(rootPath)
|
||||||
|
const parentFolder = basename(dirname(rootPath))
|
||||||
|
|
||||||
|
if (parentFolder && parentFolder !== ".") {
|
||||||
|
return `${parentFolder}-${projectFolder}`
|
||||||
|
}
|
||||||
|
return projectFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
markIndexingStarted(): void {
|
||||||
|
this.indexingInProgress = true
|
||||||
|
}
|
||||||
|
|
||||||
|
markIndexingCompleted(fileCount: number): void {
|
||||||
|
this.indexingInProgress = false
|
||||||
|
this.lastIndexedAt = Date.now()
|
||||||
|
this.fileCount = fileCount
|
||||||
|
}
|
||||||
|
|
||||||
|
markIndexingFailed(): void {
|
||||||
|
this.indexingInProgress = false
|
||||||
|
}
|
||||||
|
|
||||||
|
isIndexed(): boolean {
|
||||||
|
return this.lastIndexedAt !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
getTimeSinceIndexed(): number | null {
|
||||||
|
if (this.lastIndexedAt === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return Date.now() - this.lastIndexedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
120
packages/ipuaro/src/domain/entities/Session.ts
Normal file
120
packages/ipuaro/src/domain/entities/Session.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type { ChatMessage } from "../value-objects/ChatMessage.js"
|
||||||
|
import type { UndoEntry } from "../value-objects/UndoEntry.js"
|
||||||
|
import { MAX_UNDO_STACK_SIZE } from "../constants/index.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session statistics.
|
||||||
|
*/
|
||||||
|
export interface SessionStats {
|
||||||
|
/** Total tokens used */
|
||||||
|
totalTokens: number
|
||||||
|
/** Total time in milliseconds */
|
||||||
|
totalTimeMs: number
|
||||||
|
/** Number of tool calls made */
|
||||||
|
toolCalls: number
|
||||||
|
/** Number of edits applied */
|
||||||
|
editsApplied: number
|
||||||
|
/** Number of edits rejected */
|
||||||
|
editsRejected: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context state for the session.
|
||||||
|
*/
|
||||||
|
export interface ContextState {
|
||||||
|
/** Files currently in context */
|
||||||
|
filesInContext: string[]
|
||||||
|
/** Estimated token usage (0-1) */
|
||||||
|
tokenUsage: number
|
||||||
|
/** Whether compression is needed */
|
||||||
|
needsCompression: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session entity representing a chat session.
|
||||||
|
*/
|
||||||
|
export class Session {
|
||||||
|
readonly id: string
|
||||||
|
readonly projectName: string
|
||||||
|
readonly createdAt: number
|
||||||
|
lastActivityAt: number
|
||||||
|
history: ChatMessage[]
|
||||||
|
context: ContextState
|
||||||
|
undoStack: UndoEntry[]
|
||||||
|
stats: SessionStats
|
||||||
|
inputHistory: string[]
|
||||||
|
|
||||||
|
constructor(id: string, projectName: string, createdAt?: number) {
|
||||||
|
this.id = id
|
||||||
|
this.projectName = projectName
|
||||||
|
this.createdAt = createdAt ?? Date.now()
|
||||||
|
this.lastActivityAt = this.createdAt
|
||||||
|
this.history = []
|
||||||
|
this.context = {
|
||||||
|
filesInContext: [],
|
||||||
|
tokenUsage: 0,
|
||||||
|
needsCompression: false,
|
||||||
|
}
|
||||||
|
this.undoStack = []
|
||||||
|
this.stats = {
|
||||||
|
totalTokens: 0,
|
||||||
|
totalTimeMs: 0,
|
||||||
|
toolCalls: 0,
|
||||||
|
editsApplied: 0,
|
||||||
|
editsRejected: 0,
|
||||||
|
}
|
||||||
|
this.inputHistory = []
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessage(message: ChatMessage): void {
|
||||||
|
this.history.push(message)
|
||||||
|
this.lastActivityAt = Date.now()
|
||||||
|
|
||||||
|
if (message.stats) {
|
||||||
|
this.stats.totalTokens += message.stats.tokens
|
||||||
|
this.stats.totalTimeMs += message.stats.timeMs
|
||||||
|
this.stats.toolCalls += message.stats.toolCalls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addUndoEntry(entry: UndoEntry): void {
|
||||||
|
this.undoStack.push(entry)
|
||||||
|
if (this.undoStack.length > MAX_UNDO_STACK_SIZE) {
|
||||||
|
this.undoStack.shift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
popUndoEntry(): UndoEntry | undefined {
|
||||||
|
return this.undoStack.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
addInputToHistory(input: string): void {
|
||||||
|
if (input.trim() && this.inputHistory[this.inputHistory.length - 1] !== input) {
|
||||||
|
this.inputHistory.push(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearHistory(): void {
|
||||||
|
this.history = []
|
||||||
|
this.context = {
|
||||||
|
filesInContext: [],
|
||||||
|
tokenUsage: 0,
|
||||||
|
needsCompression: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionDurationMs(): number {
|
||||||
|
return Date.now() - this.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionDurationFormatted(): string {
|
||||||
|
const totalMinutes = Math.floor(this.getSessionDurationMs() / 60_000)
|
||||||
|
const hours = Math.floor(totalMinutes / 60)
|
||||||
|
const minutes = totalMinutes % 60
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${String(hours)}h ${String(minutes)}m`
|
||||||
|
}
|
||||||
|
return `${String(minutes)}m`
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/ipuaro/src/domain/entities/index.ts
Normal file
3
packages/ipuaro/src/domain/entities/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Domain Entities
|
||||||
|
export * from "./Session.js"
|
||||||
|
export * from "./Project.js"
|
||||||
13
packages/ipuaro/src/domain/index.ts
Normal file
13
packages/ipuaro/src/domain/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Domain Layer exports
|
||||||
|
|
||||||
|
// Entities
|
||||||
|
export * from "./entities/index.js"
|
||||||
|
|
||||||
|
// Value Objects
|
||||||
|
export * from "./value-objects/index.js"
|
||||||
|
|
||||||
|
// Service Interfaces
|
||||||
|
export * from "./services/index.js"
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
export * from "./constants/index.js"
|
||||||
83
packages/ipuaro/src/domain/services/IIndexer.ts
Normal file
83
packages/ipuaro/src/domain/services/IIndexer.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type { FileAST } from "../value-objects/FileAST.js"
|
||||||
|
import type { FileData } from "../value-objects/FileData.js"
|
||||||
|
import type { FileMeta } from "../value-objects/FileMeta.js"
|
||||||
|
import type { DepsGraph, SymbolIndex } from "./IStorage.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress callback for indexing operations.
|
||||||
|
*/
|
||||||
|
export interface IndexProgress {
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
currentFile: string
|
||||||
|
phase: "scanning" | "parsing" | "analyzing" | "indexing"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of scanning a single file.
|
||||||
|
*/
|
||||||
|
export interface ScanResult {
|
||||||
|
path: string
|
||||||
|
type: "file" | "directory" | "symlink"
|
||||||
|
size: number
|
||||||
|
lastModified: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indexing result statistics.
|
||||||
|
*/
|
||||||
|
export interface IndexingStats {
|
||||||
|
filesScanned: number
|
||||||
|
filesParsed: number
|
||||||
|
parseErrors: number
|
||||||
|
timeMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indexer service interface (port).
|
||||||
|
* Handles project scanning, parsing, and indexing.
|
||||||
|
*/
|
||||||
|
export interface IIndexer {
|
||||||
|
/**
|
||||||
|
* Scan directory and yield file results.
|
||||||
|
*/
|
||||||
|
scan(root: string): AsyncGenerator<ScanResult>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse file content into AST.
|
||||||
|
*/
|
||||||
|
parseFile(content: string, language: "ts" | "tsx" | "js" | "jsx"): FileAST
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze file and compute metadata.
|
||||||
|
*/
|
||||||
|
analyzeFile(path: string, ast: FileAST, allASTs: Map<string, FileAST>): FileMeta
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build symbol index from all ASTs.
|
||||||
|
*/
|
||||||
|
buildSymbolIndex(asts: Map<string, FileAST>): SymbolIndex
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build dependency graph from all ASTs.
|
||||||
|
*/
|
||||||
|
buildDepsGraph(asts: Map<string, FileAST>): DepsGraph
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full indexing pipeline.
|
||||||
|
*/
|
||||||
|
indexProject(
|
||||||
|
root: string,
|
||||||
|
onProgress?: (progress: IndexProgress) => void,
|
||||||
|
): Promise<IndexingStats>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update single file (incremental indexing).
|
||||||
|
*/
|
||||||
|
updateFile(path: string, data: FileData): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove file from index.
|
||||||
|
*/
|
||||||
|
removeFile(path: string): Promise<void>
|
||||||
|
}
|
||||||
81
packages/ipuaro/src/domain/services/ILLMClient.ts
Normal file
81
packages/ipuaro/src/domain/services/ILLMClient.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { ChatMessage } from "../value-objects/ChatMessage.js"
|
||||||
|
import type { ToolCall } from "../value-objects/ToolCall.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool parameter definition for LLM.
|
||||||
|
*/
|
||||||
|
export interface ToolParameter {
|
||||||
|
name: string
|
||||||
|
type: "string" | "number" | "boolean" | "array" | "object"
|
||||||
|
description: string
|
||||||
|
required: boolean
|
||||||
|
enum?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool definition for LLM function calling.
|
||||||
|
*/
|
||||||
|
export interface ToolDef {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
parameters: ToolParameter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from LLM.
|
||||||
|
*/
|
||||||
|
export interface LLMResponse {
|
||||||
|
/** Text content of the response */
|
||||||
|
content: string
|
||||||
|
/** Tool calls parsed from response */
|
||||||
|
toolCalls: ToolCall[]
|
||||||
|
/** Token count for this response */
|
||||||
|
tokens: number
|
||||||
|
/** Generation time in milliseconds */
|
||||||
|
timeMs: number
|
||||||
|
/** Whether response was truncated */
|
||||||
|
truncated: boolean
|
||||||
|
/** Stop reason */
|
||||||
|
stopReason: "end" | "length" | "tool_use"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM client service interface (port).
|
||||||
|
* Abstracts the LLM provider.
|
||||||
|
*/
|
||||||
|
export interface ILLMClient {
|
||||||
|
/**
|
||||||
|
* Send messages to LLM and get response.
|
||||||
|
*/
|
||||||
|
chat(messages: ChatMessage[], tools?: ToolDef[]): Promise<LLMResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count tokens in text.
|
||||||
|
*/
|
||||||
|
countTokens(text: string): Promise<number>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if LLM service is available.
|
||||||
|
*/
|
||||||
|
isAvailable(): Promise<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current model name.
|
||||||
|
*/
|
||||||
|
getModelName(): string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get context window size.
|
||||||
|
*/
|
||||||
|
getContextWindowSize(): number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull/download model if not available locally.
|
||||||
|
*/
|
||||||
|
pullModel(model: string): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abort current generation.
|
||||||
|
*/
|
||||||
|
abort(): void
|
||||||
|
}
|
||||||
65
packages/ipuaro/src/domain/services/IStorage.ts
Normal file
65
packages/ipuaro/src/domain/services/IStorage.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { FileData } from "../value-objects/FileData.js"
|
||||||
|
import type { FileAST } from "../value-objects/FileAST.js"
|
||||||
|
import type { FileMeta } from "../value-objects/FileMeta.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbol index mapping symbol names to their locations.
|
||||||
|
*/
|
||||||
|
export interface SymbolLocation {
|
||||||
|
path: string
|
||||||
|
line: number
|
||||||
|
type: "function" | "class" | "interface" | "type" | "variable"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SymbolIndex = Map<string, SymbolLocation[]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dependencies graph for the project.
|
||||||
|
*/
|
||||||
|
export interface DepsGraph {
|
||||||
|
/** Map from file path to its imports */
|
||||||
|
imports: Map<string, string[]>
|
||||||
|
/** Map from file path to files that import it */
|
||||||
|
importedBy: Map<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage service interface (port).
|
||||||
|
* Abstracts the persistence layer for project data.
|
||||||
|
*/
|
||||||
|
export interface IStorage {
|
||||||
|
// File data operations
|
||||||
|
getFile(path: string): Promise<FileData | null>
|
||||||
|
setFile(path: string, data: FileData): Promise<void>
|
||||||
|
deleteFile(path: string): Promise<void>
|
||||||
|
getAllFiles(): Promise<Map<string, FileData>>
|
||||||
|
getFileCount(): Promise<number>
|
||||||
|
|
||||||
|
// AST operations
|
||||||
|
getAST(path: string): Promise<FileAST | null>
|
||||||
|
setAST(path: string, ast: FileAST): Promise<void>
|
||||||
|
deleteAST(path: string): Promise<void>
|
||||||
|
getAllASTs(): Promise<Map<string, FileAST>>
|
||||||
|
|
||||||
|
// Meta operations
|
||||||
|
getMeta(path: string): Promise<FileMeta | null>
|
||||||
|
setMeta(path: string, meta: FileMeta): Promise<void>
|
||||||
|
deleteMeta(path: string): Promise<void>
|
||||||
|
getAllMetas(): Promise<Map<string, FileMeta>>
|
||||||
|
|
||||||
|
// Index operations
|
||||||
|
getSymbolIndex(): Promise<SymbolIndex>
|
||||||
|
setSymbolIndex(index: SymbolIndex): Promise<void>
|
||||||
|
getDepsGraph(): Promise<DepsGraph>
|
||||||
|
setDepsGraph(graph: DepsGraph): Promise<void>
|
||||||
|
|
||||||
|
// Config operations
|
||||||
|
getProjectConfig(key: string): Promise<unknown>
|
||||||
|
setProjectConfig(key: string, value: unknown): Promise<void>
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
connect(): Promise<void>
|
||||||
|
disconnect(): Promise<void>
|
||||||
|
isConnected(): boolean
|
||||||
|
clear(): Promise<void>
|
||||||
|
}
|
||||||
68
packages/ipuaro/src/domain/services/ITool.ts
Normal file
68
packages/ipuaro/src/domain/services/ITool.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { ToolResult } from "../value-objects/ToolResult.js"
|
||||||
|
import type { IStorage } from "./IStorage.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool parameter schema.
|
||||||
|
*/
|
||||||
|
export interface ToolParameterSchema {
|
||||||
|
name: string
|
||||||
|
type: "string" | "number" | "boolean" | "array" | "object"
|
||||||
|
description: string
|
||||||
|
required: boolean
|
||||||
|
default?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context provided to tools during execution.
|
||||||
|
*/
|
||||||
|
export interface ToolContext {
|
||||||
|
/** Project root path */
|
||||||
|
projectRoot: string
|
||||||
|
/** Storage service */
|
||||||
|
storage: IStorage
|
||||||
|
/** Request user confirmation callback */
|
||||||
|
requestConfirmation: (message: string, diff?: DiffInfo) => Promise<boolean>
|
||||||
|
/** Report progress callback */
|
||||||
|
onProgress?: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff information for confirmation dialogs.
|
||||||
|
*/
|
||||||
|
export interface DiffInfo {
|
||||||
|
filePath: string
|
||||||
|
oldLines: string[]
|
||||||
|
newLines: string[]
|
||||||
|
startLine: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool interface (port).
|
||||||
|
* All tools must implement this interface.
|
||||||
|
*/
|
||||||
|
export interface ITool {
|
||||||
|
/** Tool name (used in tool calls) */
|
||||||
|
readonly name: string
|
||||||
|
|
||||||
|
/** Human-readable description */
|
||||||
|
readonly description: string
|
||||||
|
|
||||||
|
/** Tool parameters schema */
|
||||||
|
readonly parameters: ToolParameterSchema[]
|
||||||
|
|
||||||
|
/** Whether tool requires user confirmation before execution */
|
||||||
|
readonly requiresConfirmation: boolean
|
||||||
|
|
||||||
|
/** Tool category */
|
||||||
|
readonly category: "read" | "edit" | "search" | "analysis" | "git" | "run"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the tool with given parameters.
|
||||||
|
*/
|
||||||
|
execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate parameters before execution.
|
||||||
|
*/
|
||||||
|
validateParams(params: Record<string, unknown>): string | null
|
||||||
|
}
|
||||||
5
packages/ipuaro/src/domain/services/index.ts
Normal file
5
packages/ipuaro/src/domain/services/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Domain Service Interfaces (Ports)
|
||||||
|
export * from "./IStorage.js"
|
||||||
|
export * from "./ILLMClient.js"
|
||||||
|
export * from "./ITool.js"
|
||||||
|
export * from "./IIndexer.js"
|
||||||
79
packages/ipuaro/src/domain/value-objects/ChatMessage.ts
Normal file
79
packages/ipuaro/src/domain/value-objects/ChatMessage.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { ToolCall } from "./ToolCall.js"
|
||||||
|
import type { ToolResult } from "./ToolResult.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a message in the chat history.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MessageRole = "user" | "assistant" | "tool" | "system"
|
||||||
|
|
||||||
|
export interface MessageStats {
|
||||||
|
/** Token count for this message */
|
||||||
|
tokens: number
|
||||||
|
/** Response generation time in ms (for assistant messages) */
|
||||||
|
timeMs: number
|
||||||
|
/** Number of tool calls in this message */
|
||||||
|
toolCalls: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
/** Message role */
|
||||||
|
role: MessageRole
|
||||||
|
/** Message content */
|
||||||
|
content: string
|
||||||
|
/** Timestamp when message was created */
|
||||||
|
timestamp: number
|
||||||
|
/** Tool calls made by assistant (if any) */
|
||||||
|
toolCalls?: ToolCall[]
|
||||||
|
/** Tool results (for tool role messages) */
|
||||||
|
toolResults?: ToolResult[]
|
||||||
|
/** Message statistics */
|
||||||
|
stats?: MessageStats
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUserMessage(content: string): ChatMessage {
|
||||||
|
return {
|
||||||
|
role: "user",
|
||||||
|
content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAssistantMessage(
|
||||||
|
content: string,
|
||||||
|
toolCalls?: ToolCall[],
|
||||||
|
stats?: MessageStats,
|
||||||
|
): ChatMessage {
|
||||||
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
toolCalls,
|
||||||
|
stats,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createToolMessage(results: ToolResult[]): ChatMessage {
|
||||||
|
return {
|
||||||
|
role: "tool",
|
||||||
|
content: results.map((r) => formatToolResult(r)).join("\n\n"),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
toolResults: results,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSystemMessage(content: string): ChatMessage {
|
||||||
|
return {
|
||||||
|
role: "system",
|
||||||
|
content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToolResult(result: ToolResult): string {
|
||||||
|
if (result.success) {
|
||||||
|
return `[${result.callId}] Success: ${JSON.stringify(result.data)}`
|
||||||
|
}
|
||||||
|
const errorMsg = result.error ?? "Unknown error"
|
||||||
|
return `[${result.callId}] Error: ${errorMsg}`
|
||||||
|
}
|
||||||
163
packages/ipuaro/src/domain/value-objects/FileAST.ts
Normal file
163
packages/ipuaro/src/domain/value-objects/FileAST.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Represents parsed AST information for a file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ImportInfo {
|
||||||
|
/** Import name or alias */
|
||||||
|
name: string
|
||||||
|
/** Source module path */
|
||||||
|
from: string
|
||||||
|
/** Line number of import statement */
|
||||||
|
line: number
|
||||||
|
/** Import type classification */
|
||||||
|
type: "internal" | "external" | "builtin"
|
||||||
|
/** Whether it's a default import */
|
||||||
|
isDefault: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportInfo {
|
||||||
|
/** Exported name */
|
||||||
|
name: string
|
||||||
|
/** Line number of export */
|
||||||
|
line: number
|
||||||
|
/** Whether it's a default export */
|
||||||
|
isDefault: boolean
|
||||||
|
/** Export type: function, class, variable, type */
|
||||||
|
kind: "function" | "class" | "variable" | "type" | "interface"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParameterInfo {
|
||||||
|
/** Parameter name */
|
||||||
|
name: string
|
||||||
|
/** Parameter type (if available) */
|
||||||
|
type?: string
|
||||||
|
/** Whether it's optional */
|
||||||
|
optional: boolean
|
||||||
|
/** Whether it has a default value */
|
||||||
|
hasDefault: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunctionInfo {
|
||||||
|
/** Function name */
|
||||||
|
name: string
|
||||||
|
/** Start line number */
|
||||||
|
lineStart: number
|
||||||
|
/** End line number */
|
||||||
|
lineEnd: number
|
||||||
|
/** Function parameters */
|
||||||
|
params: ParameterInfo[]
|
||||||
|
/** Whether function is async */
|
||||||
|
isAsync: boolean
|
||||||
|
/** Whether function is exported */
|
||||||
|
isExported: boolean
|
||||||
|
/** Return type (if available) */
|
||||||
|
returnType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MethodInfo {
|
||||||
|
/** Method name */
|
||||||
|
name: string
|
||||||
|
/** Start line number */
|
||||||
|
lineStart: number
|
||||||
|
/** End line number */
|
||||||
|
lineEnd: number
|
||||||
|
/** Method parameters */
|
||||||
|
params: ParameterInfo[]
|
||||||
|
/** Whether method is async */
|
||||||
|
isAsync: boolean
|
||||||
|
/** Method visibility */
|
||||||
|
visibility: "public" | "private" | "protected"
|
||||||
|
/** Whether it's static */
|
||||||
|
isStatic: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PropertyInfo {
|
||||||
|
/** Property name */
|
||||||
|
name: string
|
||||||
|
/** Line number */
|
||||||
|
line: number
|
||||||
|
/** Property type (if available) */
|
||||||
|
type?: string
|
||||||
|
/** Property visibility */
|
||||||
|
visibility: "public" | "private" | "protected"
|
||||||
|
/** Whether it's static */
|
||||||
|
isStatic: boolean
|
||||||
|
/** Whether it's readonly */
|
||||||
|
isReadonly: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClassInfo {
|
||||||
|
/** Class name */
|
||||||
|
name: string
|
||||||
|
/** Start line number */
|
||||||
|
lineStart: number
|
||||||
|
/** End line number */
|
||||||
|
lineEnd: number
|
||||||
|
/** Class methods */
|
||||||
|
methods: MethodInfo[]
|
||||||
|
/** Class properties */
|
||||||
|
properties: PropertyInfo[]
|
||||||
|
/** Extended class name */
|
||||||
|
extends?: string
|
||||||
|
/** Implemented interfaces */
|
||||||
|
implements: string[]
|
||||||
|
/** Whether class is exported */
|
||||||
|
isExported: boolean
|
||||||
|
/** Whether class is abstract */
|
||||||
|
isAbstract: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InterfaceInfo {
|
||||||
|
/** Interface name */
|
||||||
|
name: string
|
||||||
|
/** Start line number */
|
||||||
|
lineStart: number
|
||||||
|
/** End line number */
|
||||||
|
lineEnd: number
|
||||||
|
/** Interface properties */
|
||||||
|
properties: PropertyInfo[]
|
||||||
|
/** Extended interfaces */
|
||||||
|
extends: string[]
|
||||||
|
/** Whether interface is exported */
|
||||||
|
isExported: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TypeAliasInfo {
|
||||||
|
/** Type alias name */
|
||||||
|
name: string
|
||||||
|
/** Line number */
|
||||||
|
line: number
|
||||||
|
/** Whether it's exported */
|
||||||
|
isExported: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileAST {
|
||||||
|
/** Import statements */
|
||||||
|
imports: ImportInfo[]
|
||||||
|
/** Export statements */
|
||||||
|
exports: ExportInfo[]
|
||||||
|
/** Function declarations */
|
||||||
|
functions: FunctionInfo[]
|
||||||
|
/** Class declarations */
|
||||||
|
classes: ClassInfo[]
|
||||||
|
/** Interface declarations */
|
||||||
|
interfaces: InterfaceInfo[]
|
||||||
|
/** Type alias declarations */
|
||||||
|
typeAliases: TypeAliasInfo[]
|
||||||
|
/** Whether parsing encountered errors */
|
||||||
|
parseError: boolean
|
||||||
|
/** Parse error message if any */
|
||||||
|
parseErrorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyFileAST(): FileAST {
|
||||||
|
return {
|
||||||
|
imports: [],
|
||||||
|
exports: [],
|
||||||
|
functions: [],
|
||||||
|
classes: [],
|
||||||
|
interfaces: [],
|
||||||
|
typeAliases: [],
|
||||||
|
parseError: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
26
packages/ipuaro/src/domain/value-objects/FileData.ts
Normal file
26
packages/ipuaro/src/domain/value-objects/FileData.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Represents file content with metadata for change detection.
|
||||||
|
*/
|
||||||
|
export interface FileData {
|
||||||
|
/** File content split into lines */
|
||||||
|
lines: string[]
|
||||||
|
/** MD5 hash for change detection */
|
||||||
|
hash: string
|
||||||
|
/** File size in bytes */
|
||||||
|
size: number
|
||||||
|
/** Last modification timestamp (ms) */
|
||||||
|
lastModified: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFileData(
|
||||||
|
lines: string[],
|
||||||
|
hash: string,
|
||||||
|
size: number,
|
||||||
|
lastModified: number,
|
||||||
|
): FileData {
|
||||||
|
return { lines, hash, size, lastModified }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFileDataEqual(a: FileData, b: FileData): boolean {
|
||||||
|
return a.hash === b.hash
|
||||||
|
}
|
||||||
50
packages/ipuaro/src/domain/value-objects/FileMeta.ts
Normal file
50
packages/ipuaro/src/domain/value-objects/FileMeta.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Represents computed metadata about a file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ComplexityMetrics {
|
||||||
|
/** Lines of code (excluding empty and comments) */
|
||||||
|
loc: number
|
||||||
|
/** Maximum nesting depth */
|
||||||
|
nesting: number
|
||||||
|
/** Cyclomatic complexity score */
|
||||||
|
cyclomaticComplexity: number
|
||||||
|
/** Overall complexity score (0-100) */
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileMeta {
|
||||||
|
/** Complexity metrics for the file */
|
||||||
|
complexity: ComplexityMetrics
|
||||||
|
/** Files that this file imports (internal paths) */
|
||||||
|
dependencies: string[]
|
||||||
|
/** Files that import this file */
|
||||||
|
dependents: string[]
|
||||||
|
/** Whether file is a dependency hub (>5 dependents) */
|
||||||
|
isHub: boolean
|
||||||
|
/** Whether file is an entry point (index.ts or 0 dependents) */
|
||||||
|
isEntryPoint: boolean
|
||||||
|
/** File type classification */
|
||||||
|
fileType: "source" | "test" | "config" | "types" | "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFileMeta(partial: Partial<FileMeta> = {}): FileMeta {
|
||||||
|
return {
|
||||||
|
complexity: {
|
||||||
|
loc: 0,
|
||||||
|
nesting: 0,
|
||||||
|
cyclomaticComplexity: 1,
|
||||||
|
score: 0,
|
||||||
|
},
|
||||||
|
dependencies: [],
|
||||||
|
dependents: [],
|
||||||
|
isHub: false,
|
||||||
|
isEntryPoint: false,
|
||||||
|
fileType: "unknown",
|
||||||
|
...partial,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHubFile(dependentCount: number): boolean {
|
||||||
|
return dependentCount > 5
|
||||||
|
}
|
||||||
27
packages/ipuaro/src/domain/value-objects/ToolCall.ts
Normal file
27
packages/ipuaro/src/domain/value-objects/ToolCall.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Represents a tool call from the LLM.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ToolCall {
|
||||||
|
/** Unique identifier for this call */
|
||||||
|
id: string
|
||||||
|
/** Tool name */
|
||||||
|
name: string
|
||||||
|
/** Tool parameters */
|
||||||
|
params: Record<string, unknown>
|
||||||
|
/** Timestamp when call was made */
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createToolCall(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): ToolCall {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
params,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
42
packages/ipuaro/src/domain/value-objects/ToolResult.ts
Normal file
42
packages/ipuaro/src/domain/value-objects/ToolResult.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Represents the result of a tool execution.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ToolResult {
|
||||||
|
/** Tool call ID this result belongs to */
|
||||||
|
callId: string
|
||||||
|
/** Whether execution was successful */
|
||||||
|
success: boolean
|
||||||
|
/** Result data (varies by tool) */
|
||||||
|
data?: unknown
|
||||||
|
/** Error message if failed */
|
||||||
|
error?: string
|
||||||
|
/** Execution time in milliseconds */
|
||||||
|
executionTimeMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSuccessResult(
|
||||||
|
callId: string,
|
||||||
|
data: unknown,
|
||||||
|
executionTimeMs: number,
|
||||||
|
): ToolResult {
|
||||||
|
return {
|
||||||
|
callId,
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
executionTimeMs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createErrorResult(
|
||||||
|
callId: string,
|
||||||
|
error: string,
|
||||||
|
executionTimeMs: number,
|
||||||
|
): ToolResult {
|
||||||
|
return {
|
||||||
|
callId,
|
||||||
|
success: false,
|
||||||
|
error,
|
||||||
|
executionTimeMs,
|
||||||
|
}
|
||||||
|
}
|
||||||
50
packages/ipuaro/src/domain/value-objects/UndoEntry.ts
Normal file
50
packages/ipuaro/src/domain/value-objects/UndoEntry.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Represents an undo entry for file changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UndoEntry {
|
||||||
|
/** Unique identifier */
|
||||||
|
id: string
|
||||||
|
/** Timestamp when change was made */
|
||||||
|
timestamp: number
|
||||||
|
/** File path that was modified */
|
||||||
|
filePath: string
|
||||||
|
/** Content before the change */
|
||||||
|
previousContent: string[]
|
||||||
|
/** Content after the change */
|
||||||
|
newContent: string[]
|
||||||
|
/** Human-readable description of the change */
|
||||||
|
description: string
|
||||||
|
/** Tool call ID that made this change */
|
||||||
|
toolCallId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUndoEntry(
|
||||||
|
id: string,
|
||||||
|
filePath: string,
|
||||||
|
previousContent: string[],
|
||||||
|
newContent: string[],
|
||||||
|
description: string,
|
||||||
|
toolCallId?: string,
|
||||||
|
): UndoEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
filePath,
|
||||||
|
previousContent,
|
||||||
|
newContent,
|
||||||
|
description,
|
||||||
|
toolCallId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canUndo(entry: UndoEntry, currentContent: string[]): boolean {
|
||||||
|
return arraysEqual(entry.newContent, currentContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
function arraysEqual(a: string[], b: string[]): boolean {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return a.every((line, i) => line === b[i])
|
||||||
|
}
|
||||||
8
packages/ipuaro/src/domain/value-objects/index.ts
Normal file
8
packages/ipuaro/src/domain/value-objects/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// Domain Value Objects
|
||||||
|
export * from "./FileData.js"
|
||||||
|
export * from "./FileAST.js"
|
||||||
|
export * from "./FileMeta.js"
|
||||||
|
export * from "./ChatMessage.js"
|
||||||
|
export * from "./ToolCall.js"
|
||||||
|
export * from "./ToolResult.js"
|
||||||
|
export * from "./UndoEntry.js"
|
||||||
17
packages/ipuaro/src/index.ts
Normal file
17
packages/ipuaro/src/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* @puaros/ipuaro - Local AI agent for codebase operations
|
||||||
|
*
|
||||||
|
* Main entry point for the library.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Domain exports
|
||||||
|
export * from "./domain/index.js"
|
||||||
|
|
||||||
|
// Application exports
|
||||||
|
export * from "./application/index.js"
|
||||||
|
|
||||||
|
// Shared exports
|
||||||
|
export * from "./shared/index.js"
|
||||||
|
|
||||||
|
// Version
|
||||||
|
export const VERSION = "0.1.0"
|
||||||
2
packages/ipuaro/src/shared/config/index.ts
Normal file
2
packages/ipuaro/src/shared/config/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Config module exports
|
||||||
|
export * from "./loader.js"
|
||||||
89
packages/ipuaro/src/shared/config/loader.ts
Normal file
89
packages/ipuaro/src/shared/config/loader.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { Config, ConfigSchema, DEFAULT_CONFIG } from "../constants/config.js"
|
||||||
|
|
||||||
|
const CONFIG_FILE_NAME = ".ipuaro.json"
|
||||||
|
const DEFAULT_CONFIG_PATH = "config/default.json"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load configuration from files.
|
||||||
|
* Priority: .ipuaro.json > config/default.json > defaults
|
||||||
|
*/
|
||||||
|
export function loadConfig(projectRoot: string): Config {
|
||||||
|
const configs: Partial<Config>[] = []
|
||||||
|
|
||||||
|
const defaultConfigPath = join(projectRoot, DEFAULT_CONFIG_PATH)
|
||||||
|
if (existsSync(defaultConfigPath)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(defaultConfigPath, "utf-8")
|
||||||
|
configs.push(JSON.parse(content) as Partial<Config>)
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors for default config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectConfigPath = join(projectRoot, CONFIG_FILE_NAME)
|
||||||
|
if (existsSync(projectConfigPath)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(projectConfigPath, "utf-8")
|
||||||
|
configs.push(JSON.parse(content) as Partial<Config>)
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors for project config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configs.length === 0) {
|
||||||
|
return DEFAULT_CONFIG
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = deepMerge(DEFAULT_CONFIG, ...configs)
|
||||||
|
return ConfigSchema.parse(merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep merge objects.
|
||||||
|
*/
|
||||||
|
function deepMerge<T extends Record<string, unknown>>(target: T, ...sources: Partial<T>[]): T {
|
||||||
|
const result = { ...target }
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
for (const key in source) {
|
||||||
|
const sourceValue = source[key]
|
||||||
|
const targetValue = result[key]
|
||||||
|
|
||||||
|
if (isPlainObject(sourceValue) && isPlainObject(targetValue)) {
|
||||||
|
result[key] = deepMerge(
|
||||||
|
targetValue as Record<string, unknown>,
|
||||||
|
sourceValue as Record<string, unknown>,
|
||||||
|
) as T[Extract<keyof T, string>]
|
||||||
|
} else if (sourceValue !== undefined) {
|
||||||
|
result[key] = sourceValue as T[Extract<keyof T, string>]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate configuration.
|
||||||
|
*/
|
||||||
|
export function validateConfig(config: unknown): config is Config {
|
||||||
|
const result = ConfigSchema.safeParse(config)
|
||||||
|
return result.success
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get config validation errors.
|
||||||
|
*/
|
||||||
|
export function getConfigErrors(config: unknown): string[] {
|
||||||
|
const result = ConfigSchema.safeParse(config)
|
||||||
|
if (result.success) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
|
||||||
|
}
|
||||||
107
packages/ipuaro/src/shared/constants/config.ts
Normal file
107
packages/ipuaro/src/shared/constants/config.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis configuration schema.
|
||||||
|
*/
|
||||||
|
export const RedisConfigSchema = z.object({
|
||||||
|
host: z.string().default("localhost"),
|
||||||
|
port: z.number().int().positive().default(6379),
|
||||||
|
db: z.number().int().min(0).max(15).default(0),
|
||||||
|
password: z.string().optional(),
|
||||||
|
keyPrefix: z.string().default("ipuaro:"),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM configuration schema.
|
||||||
|
*/
|
||||||
|
export const LLMConfigSchema = z.object({
|
||||||
|
model: z.string().default("qwen2.5-coder:7b-instruct"),
|
||||||
|
contextWindow: z.number().int().positive().default(128_000),
|
||||||
|
temperature: z.number().min(0).max(2).default(0.1),
|
||||||
|
host: z.string().default("http://localhost:11434"),
|
||||||
|
timeout: z.number().int().positive().default(120_000),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project configuration schema.
|
||||||
|
*/
|
||||||
|
export const ProjectConfigSchema = z.object({
|
||||||
|
ignorePatterns: z
|
||||||
|
.array(z.string())
|
||||||
|
.default(["node_modules", "dist", "build", ".git", ".next", ".nuxt", "coverage", ".cache"]),
|
||||||
|
binaryExtensions: z
|
||||||
|
.array(z.string())
|
||||||
|
.default([
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".gif",
|
||||||
|
".ico",
|
||||||
|
".svg",
|
||||||
|
".woff",
|
||||||
|
".woff2",
|
||||||
|
".ttf",
|
||||||
|
".eot",
|
||||||
|
".mp3",
|
||||||
|
".mp4",
|
||||||
|
".webm",
|
||||||
|
".pdf",
|
||||||
|
".zip",
|
||||||
|
".tar",
|
||||||
|
".gz",
|
||||||
|
]),
|
||||||
|
maxFileSize: z.number().int().positive().default(1_000_000),
|
||||||
|
supportedExtensions: z
|
||||||
|
.array(z.string())
|
||||||
|
.default([".ts", ".tsx", ".js", ".jsx", ".json", ".yaml", ".yml"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watchdog configuration schema.
|
||||||
|
*/
|
||||||
|
export const WatchdogConfigSchema = z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
debounceMs: z.number().int().positive().default(500),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo configuration schema.
|
||||||
|
*/
|
||||||
|
export const UndoConfigSchema = z.object({
|
||||||
|
stackSize: z.number().int().positive().default(10),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit configuration schema.
|
||||||
|
*/
|
||||||
|
export const EditConfigSchema = z.object({
|
||||||
|
autoApply: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full configuration schema.
|
||||||
|
*/
|
||||||
|
export const ConfigSchema = z.object({
|
||||||
|
redis: RedisConfigSchema.default({}),
|
||||||
|
llm: LLMConfigSchema.default({}),
|
||||||
|
project: ProjectConfigSchema.default({}),
|
||||||
|
watchdog: WatchdogConfigSchema.default({}),
|
||||||
|
undo: UndoConfigSchema.default({}),
|
||||||
|
edit: EditConfigSchema.default({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration type inferred from schema.
|
||||||
|
*/
|
||||||
|
export type Config = z.infer<typeof ConfigSchema>
|
||||||
|
export type RedisConfig = z.infer<typeof RedisConfigSchema>
|
||||||
|
export type LLMConfig = z.infer<typeof LLMConfigSchema>
|
||||||
|
export type ProjectConfig = z.infer<typeof ProjectConfigSchema>
|
||||||
|
export type WatchdogConfig = z.infer<typeof WatchdogConfigSchema>
|
||||||
|
export type UndoConfig = z.infer<typeof UndoConfigSchema>
|
||||||
|
export type EditConfig = z.infer<typeof EditConfigSchema>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default configuration.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_CONFIG: Config = ConfigSchema.parse({})
|
||||||
3
packages/ipuaro/src/shared/constants/index.ts
Normal file
3
packages/ipuaro/src/shared/constants/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Shared constants
|
||||||
|
export * from "./config.js"
|
||||||
|
export * from "./messages.js"
|
||||||
56
packages/ipuaro/src/shared/constants/messages.ts
Normal file
56
packages/ipuaro/src/shared/constants/messages.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* User-facing messages and labels.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MESSAGES = {
|
||||||
|
// Status messages
|
||||||
|
STATUS_READY: "Ready",
|
||||||
|
STATUS_THINKING: "Thinking...",
|
||||||
|
STATUS_INDEXING: "Indexing...",
|
||||||
|
STATUS_ERROR: "Error",
|
||||||
|
|
||||||
|
// Error messages
|
||||||
|
ERROR_REDIS_UNAVAILABLE: "Redis is not available. Please start Redis server.",
|
||||||
|
ERROR_OLLAMA_UNAVAILABLE: "Ollama is not available. Please start Ollama.",
|
||||||
|
ERROR_MODEL_NOT_FOUND: "Model not found. Would you like to pull it?",
|
||||||
|
ERROR_FILE_NOT_FOUND: "File not found",
|
||||||
|
ERROR_PARSE_FAILED: "Failed to parse file",
|
||||||
|
ERROR_TOOL_FAILED: "Tool execution failed",
|
||||||
|
ERROR_COMMAND_BLACKLISTED: "Command is blacklisted for security reasons",
|
||||||
|
ERROR_PATH_OUTSIDE_PROJECT: "Path is outside project directory",
|
||||||
|
|
||||||
|
// Confirmation messages
|
||||||
|
CONFIRM_APPLY_EDIT: "Apply this edit?",
|
||||||
|
CONFIRM_DELETE_FILE: "Delete this file?",
|
||||||
|
CONFIRM_RUN_COMMAND: "Run this command?",
|
||||||
|
CONFIRM_CREATE_FILE: "Create this file?",
|
||||||
|
CONFIRM_GIT_COMMIT: "Create this commit?",
|
||||||
|
|
||||||
|
// Info messages
|
||||||
|
INFO_SESSION_LOADED: "Session loaded",
|
||||||
|
INFO_SESSION_CREATED: "New session created",
|
||||||
|
INFO_INDEXING_COMPLETE: "Indexing complete",
|
||||||
|
INFO_EDIT_APPLIED: "Edit applied",
|
||||||
|
INFO_EDIT_CANCELLED: "Edit cancelled",
|
||||||
|
INFO_UNDO_SUCCESS: "Change reverted",
|
||||||
|
INFO_UNDO_EMPTY: "Nothing to undo",
|
||||||
|
|
||||||
|
// Help text
|
||||||
|
HELP_COMMANDS: `Available commands:
|
||||||
|
/help - Show this help
|
||||||
|
/clear - Clear chat history
|
||||||
|
/undo - Revert last file change
|
||||||
|
/sessions - Manage sessions
|
||||||
|
/status - Show status info
|
||||||
|
/reindex - Force reindexing
|
||||||
|
/auto-apply - Toggle auto-apply mode`,
|
||||||
|
|
||||||
|
HELP_HOTKEYS: `Hotkeys:
|
||||||
|
Ctrl+C - Interrupt / Exit
|
||||||
|
Ctrl+D - Exit with save
|
||||||
|
Ctrl+Z - Undo last change
|
||||||
|
↑/↓ - Navigate history
|
||||||
|
Tab - Autocomplete paths`,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type MessageKey = keyof typeof MESSAGES
|
||||||
78
packages/ipuaro/src/shared/errors/IpuaroError.ts
Normal file
78
packages/ipuaro/src/shared/errors/IpuaroError.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Error types for ipuaro.
|
||||||
|
*/
|
||||||
|
export type ErrorType =
|
||||||
|
| "redis"
|
||||||
|
| "parse"
|
||||||
|
| "llm"
|
||||||
|
| "file"
|
||||||
|
| "command"
|
||||||
|
| "conflict"
|
||||||
|
| "validation"
|
||||||
|
| "timeout"
|
||||||
|
| "unknown"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base error class for ipuaro.
|
||||||
|
*/
|
||||||
|
export class IpuaroError extends Error {
|
||||||
|
readonly type: ErrorType
|
||||||
|
readonly recoverable: boolean
|
||||||
|
readonly suggestion?: string
|
||||||
|
|
||||||
|
constructor(type: ErrorType, message: string, recoverable = true, suggestion?: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = "IpuaroError"
|
||||||
|
this.type = type
|
||||||
|
this.recoverable = recoverable
|
||||||
|
this.suggestion = suggestion
|
||||||
|
}
|
||||||
|
|
||||||
|
static redis(message: string): IpuaroError {
|
||||||
|
return new IpuaroError(
|
||||||
|
"redis",
|
||||||
|
message,
|
||||||
|
false,
|
||||||
|
"Please ensure Redis is running: redis-server",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static parse(message: string, filePath?: string): IpuaroError {
|
||||||
|
const msg = filePath ? `${message} in ${filePath}` : message
|
||||||
|
return new IpuaroError("parse", msg, true, "File will be skipped")
|
||||||
|
}
|
||||||
|
|
||||||
|
static llm(message: string): IpuaroError {
|
||||||
|
return new IpuaroError(
|
||||||
|
"llm",
|
||||||
|
message,
|
||||||
|
true,
|
||||||
|
"Please ensure Ollama is running and model is available",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static file(message: string): IpuaroError {
|
||||||
|
return new IpuaroError("file", message, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static command(message: string): IpuaroError {
|
||||||
|
return new IpuaroError("command", message, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static conflict(message: string): IpuaroError {
|
||||||
|
return new IpuaroError(
|
||||||
|
"conflict",
|
||||||
|
message,
|
||||||
|
true,
|
||||||
|
"File was modified externally. Regenerate or skip.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static validation(message: string): IpuaroError {
|
||||||
|
return new IpuaroError("validation", message, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static timeout(message: string): IpuaroError {
|
||||||
|
return new IpuaroError("timeout", message, true, "Try again or increase timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
2
packages/ipuaro/src/shared/errors/index.ts
Normal file
2
packages/ipuaro/src/shared/errors/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Shared errors
|
||||||
|
export * from "./IpuaroError.js"
|
||||||
6
packages/ipuaro/src/shared/index.ts
Normal file
6
packages/ipuaro/src/shared/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Shared module exports
|
||||||
|
export * from "./config/index.js"
|
||||||
|
export * from "./constants/index.js"
|
||||||
|
export * from "./errors/index.js"
|
||||||
|
export * from "./types/index.js"
|
||||||
|
export * from "./utils/index.js"
|
||||||
66
packages/ipuaro/src/shared/types/index.ts
Normal file
66
packages/ipuaro/src/shared/types/index.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Shared types for ipuaro.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application status.
|
||||||
|
*/
|
||||||
|
export type AppStatus = "ready" | "thinking" | "indexing" | "error"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File language type.
|
||||||
|
*/
|
||||||
|
export type FileLanguage = "ts" | "tsx" | "js" | "jsx" | "json" | "yaml" | "unknown"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User choice for confirmations.
|
||||||
|
*/
|
||||||
|
export type ConfirmChoice = "apply" | "cancel" | "edit"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User choice for errors.
|
||||||
|
*/
|
||||||
|
export type ErrorChoice = "retry" | "skip" | "abort"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project structure node.
|
||||||
|
*/
|
||||||
|
export interface ProjectNode {
|
||||||
|
name: string
|
||||||
|
type: "file" | "directory"
|
||||||
|
path: string
|
||||||
|
children?: ProjectNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic result type.
|
||||||
|
*/
|
||||||
|
export type Result<T, E = Error> = { success: true; data: T } | { success: false; error: E }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create success result.
|
||||||
|
*/
|
||||||
|
export function ok<T>(data: T): Result<T, never> {
|
||||||
|
return { success: true, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create error result.
|
||||||
|
*/
|
||||||
|
export function err<E>(error: E): Result<never, E> {
|
||||||
|
return { success: false, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if result is success.
|
||||||
|
*/
|
||||||
|
export function isOk<T, E>(result: Result<T, E>): result is { success: true; data: T } {
|
||||||
|
return result.success
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if result is error.
|
||||||
|
*/
|
||||||
|
export function isErr<T, E>(result: Result<T, E>): result is { success: false; error: E } {
|
||||||
|
return !result.success
|
||||||
|
}
|
||||||
22
packages/ipuaro/src/shared/utils/hash.ts
Normal file
22
packages/ipuaro/src/shared/utils/hash.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { createHash } from "node:crypto"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate MD5 hash of content.
|
||||||
|
*/
|
||||||
|
export function md5(content: string): string {
|
||||||
|
return createHash("md5").update(content).digest("hex")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate MD5 hash of file lines.
|
||||||
|
*/
|
||||||
|
export function hashLines(lines: string[]): string {
|
||||||
|
return md5(lines.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate short hash for IDs.
|
||||||
|
*/
|
||||||
|
export function shortHash(content: string, length = 8): string {
|
||||||
|
return md5(content).slice(0, length)
|
||||||
|
}
|
||||||
3
packages/ipuaro/src/shared/utils/index.ts
Normal file
3
packages/ipuaro/src/shared/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Shared utilities
|
||||||
|
export * from "./hash.js"
|
||||||
|
export * from "./tokens.js"
|
||||||
41
packages/ipuaro/src/shared/utils/tokens.ts
Normal file
41
packages/ipuaro/src/shared/utils/tokens.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Simple token estimation utilities.
|
||||||
|
* Uses approximation: ~4 characters per token for English text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CHARS_PER_TOKEN = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate token count for text.
|
||||||
|
*/
|
||||||
|
export function estimateTokens(text: string): number {
|
||||||
|
return Math.ceil(text.length / CHARS_PER_TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate token count for array of strings.
|
||||||
|
*/
|
||||||
|
export function estimateTokensForLines(lines: string[]): number {
|
||||||
|
return estimateTokens(lines.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate text to approximate token limit.
|
||||||
|
*/
|
||||||
|
export function truncateToTokens(text: string, maxTokens: number): string {
|
||||||
|
const maxChars = maxTokens * CHARS_PER_TOKEN
|
||||||
|
if (text.length <= maxChars) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return `${text.slice(0, maxChars)}...`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format token count for display.
|
||||||
|
*/
|
||||||
|
export function formatTokenCount(tokens: number): string {
|
||||||
|
if (tokens >= 1000) {
|
||||||
|
return `${(tokens / 1000).toFixed(1)}k`
|
||||||
|
}
|
||||||
|
return tokens.toString()
|
||||||
|
}
|
||||||
106
packages/ipuaro/tests/unit/domain/entities/Project.test.ts
Normal file
106
packages/ipuaro/tests/unit/domain/entities/Project.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||||
|
import { Project } from "../../../../src/domain/entities/Project.js"
|
||||||
|
|
||||||
|
describe("Project", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("constructor", () => {
|
||||||
|
it("should create project with generated name", () => {
|
||||||
|
const project = new Project("/home/user/projects/myapp")
|
||||||
|
|
||||||
|
expect(project.rootPath).toBe("/home/user/projects/myapp")
|
||||||
|
expect(project.name).toBe("projects-myapp")
|
||||||
|
expect(project.createdAt).toBe(Date.now())
|
||||||
|
expect(project.lastIndexedAt).toBeNull()
|
||||||
|
expect(project.fileCount).toBe(0)
|
||||||
|
expect(project.indexingInProgress).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should accept custom createdAt", () => {
|
||||||
|
const customTime = 1000000
|
||||||
|
const project = new Project("/path", customTime)
|
||||||
|
|
||||||
|
expect(project.createdAt).toBe(customTime)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("generateProjectName", () => {
|
||||||
|
it("should generate name from parent and project folder", () => {
|
||||||
|
expect(Project.generateProjectName("/home/user/projects/myapp")).toBe("projects-myapp")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle root-level project", () => {
|
||||||
|
expect(Project.generateProjectName("/myapp")).toBe("myapp")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("indexing lifecycle", () => {
|
||||||
|
it("should mark indexing started", () => {
|
||||||
|
const project = new Project("/path")
|
||||||
|
|
||||||
|
project.markIndexingStarted()
|
||||||
|
|
||||||
|
expect(project.indexingInProgress).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should mark indexing completed", () => {
|
||||||
|
const project = new Project("/path")
|
||||||
|
project.markIndexingStarted()
|
||||||
|
|
||||||
|
project.markIndexingCompleted(100)
|
||||||
|
|
||||||
|
expect(project.indexingInProgress).toBe(false)
|
||||||
|
expect(project.lastIndexedAt).toBe(Date.now())
|
||||||
|
expect(project.fileCount).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should mark indexing failed", () => {
|
||||||
|
const project = new Project("/path")
|
||||||
|
project.markIndexingStarted()
|
||||||
|
|
||||||
|
project.markIndexingFailed()
|
||||||
|
|
||||||
|
expect(project.indexingInProgress).toBe(false)
|
||||||
|
expect(project.lastIndexedAt).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isIndexed", () => {
|
||||||
|
it("should return false when not indexed", () => {
|
||||||
|
const project = new Project("/path")
|
||||||
|
|
||||||
|
expect(project.isIndexed()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return true when indexed", () => {
|
||||||
|
const project = new Project("/path")
|
||||||
|
project.markIndexingCompleted(10)
|
||||||
|
|
||||||
|
expect(project.isIndexed()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getTimeSinceIndexed", () => {
|
||||||
|
it("should return null when not indexed", () => {
|
||||||
|
const project = new Project("/path")
|
||||||
|
|
||||||
|
expect(project.getTimeSinceIndexed()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return time since last indexed", () => {
|
||||||
|
const project = new Project("/path")
|
||||||
|
project.markIndexingCompleted(10)
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000)
|
||||||
|
|
||||||
|
expect(project.getTimeSinceIndexed()).toBe(5000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
165
packages/ipuaro/tests/unit/domain/entities/Session.test.ts
Normal file
165
packages/ipuaro/tests/unit/domain/entities/Session.test.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||||
|
import { Session } from "../../../../src/domain/entities/Session.js"
|
||||||
|
import { createUserMessage } from "../../../../src/domain/value-objects/ChatMessage.js"
|
||||||
|
import type { UndoEntry } from "../../../../src/domain/value-objects/UndoEntry.js"
|
||||||
|
|
||||||
|
describe("Session", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create session with defaults", () => {
|
||||||
|
const session = new Session("session-1", "test-project")
|
||||||
|
|
||||||
|
expect(session.id).toBe("session-1")
|
||||||
|
expect(session.projectName).toBe("test-project")
|
||||||
|
expect(session.history).toEqual([])
|
||||||
|
expect(session.undoStack).toEqual([])
|
||||||
|
expect(session.stats.totalTokens).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("addMessage", () => {
|
||||||
|
it("should add message to history", () => {
|
||||||
|
const session = new Session("1", "proj")
|
||||||
|
const msg = createUserMessage("Hello")
|
||||||
|
|
||||||
|
session.addMessage(msg)
|
||||||
|
|
||||||
|
expect(session.history).toHaveLength(1)
|
||||||
|
expect(session.history[0]).toBe(msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should update stats from message", () => {
|
||||||
|
const session = new Session("1", "proj")
|
||||||
|
const msg = {
|
||||||
|
role: "assistant" as const,
|
||||||
|
content: "Hi",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
stats: { tokens: 50, timeMs: 100, toolCalls: 2 },
|
||||||
|
}
|
||||||
|
|
||||||
|
session.addMessage(msg)
|
||||||
|
|
||||||
|
expect(session.stats.totalTokens).toBe(50)
|
||||||
|
expect(session.stats.totalTimeMs).toBe(100)
|
||||||
|
expect(session.stats.toolCalls).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("undoStack", () => {
|
||||||
|
it("should add undo entry", () => {
|
||||||
|
const session = new Session("1", "proj")
|
||||||
|
const entry: UndoEntry = {
|
||||||
|
id: "undo-1",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
filePath: "test.ts",
|
||||||
|
previousContent: ["old"],
|
||||||
|
newContent: ["new"],
|
||||||
|
description: "Edit",
|
||||||
|
}
|
||||||
|
|
||||||
|
session.addUndoEntry(entry)
|
||||||
|
|
||||||
|
expect(session.undoStack).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should limit undo stack size", () => {
|
||||||
|
const session = new Session("1", "proj")
|
||||||
|
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
session.addUndoEntry({
|
||||||
|
id: `undo-${i}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
filePath: "test.ts",
|
||||||
|
previousContent: [],
|
||||||
|
newContent: [],
|
||||||
|
description: `Edit ${i}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(session.undoStack).toHaveLength(10)
|
||||||
|
expect(session.undoStack[0].id).toBe("undo-5")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should pop undo entry", () => {
|
||||||
|
const session = new Session("1", "proj")
|
||||||
|
const entry: UndoEntry = {
|
||||||
|
id: "undo-1",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
filePath: "test.ts",
|
||||||
|
previousContent: [],
|
||||||
|
newContent: [],
|
||||||
|
description: "Edit",
|
||||||
|
}
|
||||||
|
|
||||||
|
session.addUndoEntry(entry)
|
||||||
|
const popped = session.popUndoEntry()
|
||||||
|
|
||||||
|
expect(popped).toBe(entry)
|
||||||
|
expect(session.undoStack).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("inputHistory", () => {
|
||||||
|
it("should add input to history", () => {
|
||||||
|
const session = new Session("1", "proj")
|
||||||
|
|
||||||
|
session.addInputToHistory("command 1")
|
||||||
|
session.addInputToHistory("command 2")
|
||||||
|
|
||||||
|
expect(session.inputHistory).toEqual(["command 1", "command 2"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not add duplicate consecutive inputs", () => {
|
||||||
|
const session = new Session("1", "proj")
|
||||||
|
|
||||||
|
session.addInputToHistory("command")
|
||||||
|
session.addInputToHistory("command")
|
||||||
|
|
||||||
|
expect(session.inputHistory).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not add empty inputs", () => {
|
||||||
|
const session = new Session("1", "proj")
|
||||||
|
|
||||||
|
session.addInputToHistory("")
|
||||||
|
session.addInputToHistory(" ")
|
||||||
|
|
||||||
|
expect(session.inputHistory).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("clearHistory", () => {
|
||||||
|
it("should clear history and context", () => {
|
||||||
|
const session = new Session("1", "proj")
|
||||||
|
session.addMessage(createUserMessage("Hello"))
|
||||||
|
session.context.filesInContext = ["file1.ts"]
|
||||||
|
|
||||||
|
session.clearHistory()
|
||||||
|
|
||||||
|
expect(session.history).toHaveLength(0)
|
||||||
|
expect(session.context.filesInContext).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getSessionDurationFormatted", () => {
|
||||||
|
it("should format minutes only", () => {
|
||||||
|
const session = new Session("1", "proj")
|
||||||
|
vi.advanceTimersByTime(15 * 60 * 1000)
|
||||||
|
|
||||||
|
expect(session.getSessionDurationFormatted()).toBe("15m")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should format hours and minutes", () => {
|
||||||
|
const session = new Session("1", "proj")
|
||||||
|
vi.advanceTimersByTime(90 * 60 * 1000)
|
||||||
|
|
||||||
|
expect(session.getSessionDurationFormatted()).toBe("1h 30m")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||||
|
import {
|
||||||
|
createUserMessage,
|
||||||
|
createAssistantMessage,
|
||||||
|
createToolMessage,
|
||||||
|
createSystemMessage,
|
||||||
|
} from "../../../../src/domain/value-objects/ChatMessage.js"
|
||||||
|
|
||||||
|
describe("ChatMessage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createUserMessage", () => {
|
||||||
|
it("should create user message", () => {
|
||||||
|
const msg = createUserMessage("Hello")
|
||||||
|
|
||||||
|
expect(msg.role).toBe("user")
|
||||||
|
expect(msg.content).toBe("Hello")
|
||||||
|
expect(msg.timestamp).toBe(Date.now())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createAssistantMessage", () => {
|
||||||
|
it("should create assistant message without tool calls", () => {
|
||||||
|
const msg = createAssistantMessage("Response")
|
||||||
|
|
||||||
|
expect(msg.role).toBe("assistant")
|
||||||
|
expect(msg.content).toBe("Response")
|
||||||
|
expect(msg.toolCalls).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create assistant message with tool calls", () => {
|
||||||
|
const toolCalls = [
|
||||||
|
{ id: "1", name: "get_lines", params: {}, timestamp: Date.now() },
|
||||||
|
]
|
||||||
|
const stats = { tokens: 100, timeMs: 500, toolCalls: 1 }
|
||||||
|
const msg = createAssistantMessage("Response", toolCalls, stats)
|
||||||
|
|
||||||
|
expect(msg.toolCalls).toEqual(toolCalls)
|
||||||
|
expect(msg.stats).toEqual(stats)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createToolMessage", () => {
|
||||||
|
it("should create tool message with results", () => {
|
||||||
|
const results = [
|
||||||
|
{ callId: "1", success: true, data: "data", executionTimeMs: 10 },
|
||||||
|
]
|
||||||
|
const msg = createToolMessage(results)
|
||||||
|
|
||||||
|
expect(msg.role).toBe("tool")
|
||||||
|
expect(msg.toolResults).toEqual(results)
|
||||||
|
expect(msg.content).toContain("[1] Success")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should format error results", () => {
|
||||||
|
const results = [
|
||||||
|
{ callId: "2", success: false, error: "Not found", executionTimeMs: 5 },
|
||||||
|
]
|
||||||
|
const msg = createToolMessage(results)
|
||||||
|
|
||||||
|
expect(msg.content).toContain("[2] Error: Not found")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createSystemMessage", () => {
|
||||||
|
it("should create system message", () => {
|
||||||
|
const msg = createSystemMessage("System prompt")
|
||||||
|
|
||||||
|
expect(msg.role).toBe("system")
|
||||||
|
expect(msg.content).toBe("System prompt")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import { createEmptyFileAST } from "../../../../src/domain/value-objects/FileAST.js"
|
||||||
|
|
||||||
|
describe("FileAST", () => {
|
||||||
|
describe("createEmptyFileAST", () => {
|
||||||
|
it("should create empty AST with all arrays empty", () => {
|
||||||
|
const ast = createEmptyFileAST()
|
||||||
|
|
||||||
|
expect(ast.imports).toEqual([])
|
||||||
|
expect(ast.exports).toEqual([])
|
||||||
|
expect(ast.functions).toEqual([])
|
||||||
|
expect(ast.classes).toEqual([])
|
||||||
|
expect(ast.interfaces).toEqual([])
|
||||||
|
expect(ast.typeAliases).toEqual([])
|
||||||
|
expect(ast.parseError).toBe(false)
|
||||||
|
expect(ast.parseErrorMessage).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import {
|
||||||
|
createFileData,
|
||||||
|
isFileDataEqual,
|
||||||
|
} from "../../../../src/domain/value-objects/FileData.js"
|
||||||
|
|
||||||
|
describe("FileData", () => {
|
||||||
|
describe("createFileData", () => {
|
||||||
|
it("should create FileData with all fields", () => {
|
||||||
|
const lines = ["line1", "line2"]
|
||||||
|
const hash = "abc123"
|
||||||
|
const size = 100
|
||||||
|
const lastModified = Date.now()
|
||||||
|
|
||||||
|
const result = createFileData(lines, hash, size, lastModified)
|
||||||
|
|
||||||
|
expect(result.lines).toEqual(lines)
|
||||||
|
expect(result.hash).toBe(hash)
|
||||||
|
expect(result.size).toBe(size)
|
||||||
|
expect(result.lastModified).toBe(lastModified)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isFileDataEqual", () => {
|
||||||
|
it("should return true for equal hashes", () => {
|
||||||
|
const a = createFileData(["a"], "hash1", 1, 1)
|
||||||
|
const b = createFileData(["b"], "hash1", 2, 2)
|
||||||
|
|
||||||
|
expect(isFileDataEqual(a, b)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false for different hashes", () => {
|
||||||
|
const a = createFileData(["a"], "hash1", 1, 1)
|
||||||
|
const b = createFileData(["a"], "hash2", 1, 1)
|
||||||
|
|
||||||
|
expect(isFileDataEqual(a, b)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import {
|
||||||
|
createFileMeta,
|
||||||
|
isHubFile,
|
||||||
|
} from "../../../../src/domain/value-objects/FileMeta.js"
|
||||||
|
|
||||||
|
describe("FileMeta", () => {
|
||||||
|
describe("createFileMeta", () => {
|
||||||
|
it("should create FileMeta with defaults", () => {
|
||||||
|
const meta = createFileMeta()
|
||||||
|
|
||||||
|
expect(meta.complexity.loc).toBe(0)
|
||||||
|
expect(meta.complexity.nesting).toBe(0)
|
||||||
|
expect(meta.complexity.cyclomaticComplexity).toBe(1)
|
||||||
|
expect(meta.complexity.score).toBe(0)
|
||||||
|
expect(meta.dependencies).toEqual([])
|
||||||
|
expect(meta.dependents).toEqual([])
|
||||||
|
expect(meta.isHub).toBe(false)
|
||||||
|
expect(meta.isEntryPoint).toBe(false)
|
||||||
|
expect(meta.fileType).toBe("unknown")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should merge partial values", () => {
|
||||||
|
const meta = createFileMeta({
|
||||||
|
isHub: true,
|
||||||
|
fileType: "source",
|
||||||
|
dependencies: ["dep1.ts"],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(meta.isHub).toBe(true)
|
||||||
|
expect(meta.fileType).toBe("source")
|
||||||
|
expect(meta.dependencies).toEqual(["dep1.ts"])
|
||||||
|
expect(meta.dependents).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isHubFile", () => {
|
||||||
|
it("should return true for >5 dependents", () => {
|
||||||
|
expect(isHubFile(6)).toBe(true)
|
||||||
|
expect(isHubFile(10)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false for <=5 dependents", () => {
|
||||||
|
expect(isHubFile(5)).toBe(false)
|
||||||
|
expect(isHubFile(0)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||||
|
import { createToolCall } from "../../../../src/domain/value-objects/ToolCall.js"
|
||||||
|
|
||||||
|
describe("ToolCall", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createToolCall", () => {
|
||||||
|
it("should create tool call with all fields", () => {
|
||||||
|
const params = { path: "test.ts", line: 10 }
|
||||||
|
const call = createToolCall("call-1", "get_lines", params)
|
||||||
|
|
||||||
|
expect(call.id).toBe("call-1")
|
||||||
|
expect(call.name).toBe("get_lines")
|
||||||
|
expect(call.params).toEqual(params)
|
||||||
|
expect(call.timestamp).toBe(Date.now())
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty params", () => {
|
||||||
|
const call = createToolCall("call-2", "git_status", {})
|
||||||
|
|
||||||
|
expect(call.params).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import {
|
||||||
|
createSuccessResult,
|
||||||
|
createErrorResult,
|
||||||
|
} from "../../../../src/domain/value-objects/ToolResult.js"
|
||||||
|
|
||||||
|
describe("ToolResult", () => {
|
||||||
|
describe("createSuccessResult", () => {
|
||||||
|
it("should create success result", () => {
|
||||||
|
const data = { lines: ["line1", "line2"] }
|
||||||
|
const result = createSuccessResult("call-1", data, 50)
|
||||||
|
|
||||||
|
expect(result.callId).toBe("call-1")
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data).toEqual(data)
|
||||||
|
expect(result.executionTimeMs).toBe(50)
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createErrorResult", () => {
|
||||||
|
it("should create error result", () => {
|
||||||
|
const result = createErrorResult("call-2", "File not found", 10)
|
||||||
|
|
||||||
|
expect(result.callId).toBe("call-2")
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe("File not found")
|
||||||
|
expect(result.executionTimeMs).toBe(10)
|
||||||
|
expect(result.data).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||||
|
import {
|
||||||
|
createUndoEntry,
|
||||||
|
canUndo,
|
||||||
|
} from "../../../../src/domain/value-objects/UndoEntry.js"
|
||||||
|
|
||||||
|
describe("UndoEntry", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createUndoEntry", () => {
|
||||||
|
it("should create undo entry with all fields", () => {
|
||||||
|
const entry = createUndoEntry(
|
||||||
|
"undo-1",
|
||||||
|
"test.ts",
|
||||||
|
["old line"],
|
||||||
|
["new line"],
|
||||||
|
"Edit line 1"
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(entry.id).toBe("undo-1")
|
||||||
|
expect(entry.filePath).toBe("test.ts")
|
||||||
|
expect(entry.previousContent).toEqual(["old line"])
|
||||||
|
expect(entry.newContent).toEqual(["new line"])
|
||||||
|
expect(entry.description).toBe("Edit line 1")
|
||||||
|
expect(entry.timestamp).toBe(Date.now())
|
||||||
|
expect(entry.toolCallId).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create undo entry with toolCallId", () => {
|
||||||
|
const entry = createUndoEntry(
|
||||||
|
"undo-2",
|
||||||
|
"test.ts",
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
"Create file",
|
||||||
|
"tool-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(entry.toolCallId).toBe("tool-123")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("canUndo", () => {
|
||||||
|
it("should return true when current content matches newContent", () => {
|
||||||
|
const entry = createUndoEntry(
|
||||||
|
"undo-1",
|
||||||
|
"test.ts",
|
||||||
|
["old"],
|
||||||
|
["new"],
|
||||||
|
"Edit"
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(canUndo(entry, ["new"])).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false when content differs", () => {
|
||||||
|
const entry = createUndoEntry(
|
||||||
|
"undo-1",
|
||||||
|
"test.ts",
|
||||||
|
["old"],
|
||||||
|
["new"],
|
||||||
|
"Edit"
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(canUndo(entry, ["modified"])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false when length differs", () => {
|
||||||
|
const entry = createUndoEntry(
|
||||||
|
"undo-1",
|
||||||
|
"test.ts",
|
||||||
|
["old"],
|
||||||
|
["new"],
|
||||||
|
"Edit"
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(canUndo(entry, ["new", "extra"])).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
80
packages/ipuaro/tests/unit/shared/config/loader.test.ts
Normal file
80
packages/ipuaro/tests/unit/shared/config/loader.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||||
|
import { loadConfig, validateConfig, getConfigErrors } from "../../../../src/shared/config/loader.js"
|
||||||
|
import { DEFAULT_CONFIG } from "../../../../src/shared/constants/config.js"
|
||||||
|
import * as fs from "node:fs"
|
||||||
|
|
||||||
|
vi.mock("node:fs")
|
||||||
|
|
||||||
|
describe("config loader", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("loadConfig", () => {
|
||||||
|
it("should return default config when no files exist", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(false)
|
||||||
|
|
||||||
|
const config = loadConfig("/project")
|
||||||
|
|
||||||
|
expect(config).toEqual(DEFAULT_CONFIG)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should merge project config with defaults", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockImplementation((path) => {
|
||||||
|
return path === "/project/.ipuaro.json"
|
||||||
|
})
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||||
|
JSON.stringify({ llm: { model: "custom-model" } })
|
||||||
|
)
|
||||||
|
|
||||||
|
const config = loadConfig("/project")
|
||||||
|
|
||||||
|
expect(config.llm.model).toBe("custom-model")
|
||||||
|
expect(config.redis.host).toBe("localhost")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle invalid JSON gracefully", () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue("invalid json")
|
||||||
|
|
||||||
|
const config = loadConfig("/project")
|
||||||
|
|
||||||
|
expect(config).toEqual(DEFAULT_CONFIG)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("validateConfig", () => {
|
||||||
|
it("should return true for valid config", () => {
|
||||||
|
expect(validateConfig(DEFAULT_CONFIG)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return true for partial valid config", () => {
|
||||||
|
expect(validateConfig({ redis: { host: "redis.local" } })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false for invalid config", () => {
|
||||||
|
expect(validateConfig({ redis: { port: "not a number" } })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getConfigErrors", () => {
|
||||||
|
it("should return empty array for valid config", () => {
|
||||||
|
const errors = getConfigErrors(DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
expect(errors).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return errors for invalid config", () => {
|
||||||
|
const errors = getConfigErrors({
|
||||||
|
redis: { port: "invalid" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(errors.length).toBeGreaterThan(0)
|
||||||
|
expect(errors[0]).toContain("redis.port")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
48
packages/ipuaro/tests/unit/shared/constants/messages.test.ts
Normal file
48
packages/ipuaro/tests/unit/shared/constants/messages.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import { MESSAGES } from "../../../../src/shared/constants/messages.js"
|
||||||
|
|
||||||
|
describe("MESSAGES", () => {
|
||||||
|
it("should have status messages", () => {
|
||||||
|
expect(MESSAGES.STATUS_READY).toBe("Ready")
|
||||||
|
expect(MESSAGES.STATUS_THINKING).toBe("Thinking...")
|
||||||
|
expect(MESSAGES.STATUS_INDEXING).toBe("Indexing...")
|
||||||
|
expect(MESSAGES.STATUS_ERROR).toBe("Error")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have error messages", () => {
|
||||||
|
expect(MESSAGES.ERROR_REDIS_UNAVAILABLE).toContain("Redis")
|
||||||
|
expect(MESSAGES.ERROR_OLLAMA_UNAVAILABLE).toContain("Ollama")
|
||||||
|
expect(MESSAGES.ERROR_MODEL_NOT_FOUND).toContain("Model")
|
||||||
|
expect(MESSAGES.ERROR_FILE_NOT_FOUND).toBe("File not found")
|
||||||
|
expect(MESSAGES.ERROR_PARSE_FAILED).toContain("parse")
|
||||||
|
expect(MESSAGES.ERROR_TOOL_FAILED).toContain("Tool")
|
||||||
|
expect(MESSAGES.ERROR_COMMAND_BLACKLISTED).toContain("blacklisted")
|
||||||
|
expect(MESSAGES.ERROR_PATH_OUTSIDE_PROJECT).toContain("outside")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have confirmation messages", () => {
|
||||||
|
expect(MESSAGES.CONFIRM_APPLY_EDIT).toContain("Apply")
|
||||||
|
expect(MESSAGES.CONFIRM_DELETE_FILE).toContain("Delete")
|
||||||
|
expect(MESSAGES.CONFIRM_RUN_COMMAND).toContain("Run")
|
||||||
|
expect(MESSAGES.CONFIRM_CREATE_FILE).toContain("Create")
|
||||||
|
expect(MESSAGES.CONFIRM_GIT_COMMIT).toContain("commit")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have info messages", () => {
|
||||||
|
expect(MESSAGES.INFO_SESSION_LOADED).toContain("loaded")
|
||||||
|
expect(MESSAGES.INFO_SESSION_CREATED).toContain("created")
|
||||||
|
expect(MESSAGES.INFO_INDEXING_COMPLETE).toContain("complete")
|
||||||
|
expect(MESSAGES.INFO_EDIT_APPLIED).toContain("applied")
|
||||||
|
expect(MESSAGES.INFO_EDIT_CANCELLED).toContain("cancelled")
|
||||||
|
expect(MESSAGES.INFO_UNDO_SUCCESS).toContain("reverted")
|
||||||
|
expect(MESSAGES.INFO_UNDO_EMPTY).toContain("Nothing")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have help text", () => {
|
||||||
|
expect(MESSAGES.HELP_COMMANDS).toContain("/help")
|
||||||
|
expect(MESSAGES.HELP_COMMANDS).toContain("/clear")
|
||||||
|
expect(MESSAGES.HELP_COMMANDS).toContain("/undo")
|
||||||
|
expect(MESSAGES.HELP_HOTKEYS).toContain("Ctrl+C")
|
||||||
|
expect(MESSAGES.HELP_HOTKEYS).toContain("Ctrl+D")
|
||||||
|
})
|
||||||
|
})
|
||||||
86
packages/ipuaro/tests/unit/shared/errors/IpuaroError.test.ts
Normal file
86
packages/ipuaro/tests/unit/shared/errors/IpuaroError.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import { IpuaroError } from "../../../../src/shared/errors/IpuaroError.js"
|
||||||
|
|
||||||
|
describe("IpuaroError", () => {
|
||||||
|
describe("constructor", () => {
|
||||||
|
it("should create error with all fields", () => {
|
||||||
|
const error = new IpuaroError("file", "Not found", true, "Check path")
|
||||||
|
|
||||||
|
expect(error.name).toBe("IpuaroError")
|
||||||
|
expect(error.type).toBe("file")
|
||||||
|
expect(error.message).toBe("Not found")
|
||||||
|
expect(error.recoverable).toBe(true)
|
||||||
|
expect(error.suggestion).toBe("Check path")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should default recoverable to true", () => {
|
||||||
|
const error = new IpuaroError("parse", "Parse failed")
|
||||||
|
|
||||||
|
expect(error.recoverable).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("static factories", () => {
|
||||||
|
it("should create redis error", () => {
|
||||||
|
const error = IpuaroError.redis("Connection failed")
|
||||||
|
|
||||||
|
expect(error.type).toBe("redis")
|
||||||
|
expect(error.recoverable).toBe(false)
|
||||||
|
expect(error.suggestion).toContain("Redis")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create parse error", () => {
|
||||||
|
const error = IpuaroError.parse("Syntax error", "test.ts")
|
||||||
|
|
||||||
|
expect(error.type).toBe("parse")
|
||||||
|
expect(error.message).toContain("test.ts")
|
||||||
|
expect(error.recoverable).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create parse error without file", () => {
|
||||||
|
const error = IpuaroError.parse("Syntax error")
|
||||||
|
|
||||||
|
expect(error.message).toBe("Syntax error")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create llm error", () => {
|
||||||
|
const error = IpuaroError.llm("Timeout")
|
||||||
|
|
||||||
|
expect(error.type).toBe("llm")
|
||||||
|
expect(error.recoverable).toBe(true)
|
||||||
|
expect(error.suggestion).toContain("Ollama")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create file error", () => {
|
||||||
|
const error = IpuaroError.file("Not found")
|
||||||
|
|
||||||
|
expect(error.type).toBe("file")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create command error", () => {
|
||||||
|
const error = IpuaroError.command("Blacklisted")
|
||||||
|
|
||||||
|
expect(error.type).toBe("command")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create conflict error", () => {
|
||||||
|
const error = IpuaroError.conflict("File changed")
|
||||||
|
|
||||||
|
expect(error.type).toBe("conflict")
|
||||||
|
expect(error.suggestion).toContain("Regenerate")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create validation error", () => {
|
||||||
|
const error = IpuaroError.validation("Invalid param")
|
||||||
|
|
||||||
|
expect(error.type).toBe("validation")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create timeout error", () => {
|
||||||
|
const error = IpuaroError.timeout("Request timeout")
|
||||||
|
|
||||||
|
expect(error.type).toBe("timeout")
|
||||||
|
expect(error.suggestion).toContain("timeout")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
51
packages/ipuaro/tests/unit/shared/types/index.test.ts
Normal file
51
packages/ipuaro/tests/unit/shared/types/index.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import { ok, err, isOk, isErr, type Result } from "../../../../src/shared/types/index.js"
|
||||||
|
|
||||||
|
describe("Result type", () => {
|
||||||
|
describe("ok", () => {
|
||||||
|
it("should create success result", () => {
|
||||||
|
const result = ok("data")
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data).toBe("data")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("err", () => {
|
||||||
|
it("should create error result", () => {
|
||||||
|
const error = new Error("failed")
|
||||||
|
const result = err(error)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isOk", () => {
|
||||||
|
it("should return true for success", () => {
|
||||||
|
const result: Result<string> = ok("data")
|
||||||
|
|
||||||
|
expect(isOk(result)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false for error", () => {
|
||||||
|
const result: Result<string> = err(new Error("fail"))
|
||||||
|
|
||||||
|
expect(isOk(result)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isErr", () => {
|
||||||
|
it("should return true for error", () => {
|
||||||
|
const result: Result<string> = err(new Error("fail"))
|
||||||
|
|
||||||
|
expect(isErr(result)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return false for success", () => {
|
||||||
|
const result: Result<string> = ok("data")
|
||||||
|
|
||||||
|
expect(isErr(result)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
56
packages/ipuaro/tests/unit/shared/utils/hash.test.ts
Normal file
56
packages/ipuaro/tests/unit/shared/utils/hash.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import { md5, hashLines, shortHash } from "../../../../src/shared/utils/hash.js"
|
||||||
|
|
||||||
|
describe("hash utils", () => {
|
||||||
|
describe("md5", () => {
|
||||||
|
it("should return consistent hash for same input", () => {
|
||||||
|
const hash1 = md5("hello")
|
||||||
|
const hash2 = md5("hello")
|
||||||
|
|
||||||
|
expect(hash1).toBe(hash2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return different hash for different input", () => {
|
||||||
|
const hash1 = md5("hello")
|
||||||
|
const hash2 = md5("world")
|
||||||
|
|
||||||
|
expect(hash1).not.toBe(hash2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return 32 character hex string", () => {
|
||||||
|
const hash = md5("test")
|
||||||
|
|
||||||
|
expect(hash).toHaveLength(32)
|
||||||
|
expect(hash).toMatch(/^[a-f0-9]+$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("hashLines", () => {
|
||||||
|
it("should hash joined lines", () => {
|
||||||
|
const lines = ["line1", "line2", "line3"]
|
||||||
|
const hash = hashLines(lines)
|
||||||
|
|
||||||
|
expect(hash).toBe(md5("line1\nline2\nline3"))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty array", () => {
|
||||||
|
const hash = hashLines([])
|
||||||
|
|
||||||
|
expect(hash).toBe(md5(""))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("shortHash", () => {
|
||||||
|
it("should return truncated hash", () => {
|
||||||
|
const hash = shortHash("test")
|
||||||
|
|
||||||
|
expect(hash).toHaveLength(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should accept custom length", () => {
|
||||||
|
const hash = shortHash("test", 12)
|
||||||
|
|
||||||
|
expect(hash).toHaveLength(12)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
61
packages/ipuaro/tests/unit/shared/utils/tokens.test.ts
Normal file
61
packages/ipuaro/tests/unit/shared/utils/tokens.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import {
|
||||||
|
estimateTokens,
|
||||||
|
estimateTokensForLines,
|
||||||
|
truncateToTokens,
|
||||||
|
formatTokenCount,
|
||||||
|
} from "../../../../src/shared/utils/tokens.js"
|
||||||
|
|
||||||
|
describe("tokens utils", () => {
|
||||||
|
describe("estimateTokens", () => {
|
||||||
|
it("should estimate ~4 chars per token", () => {
|
||||||
|
expect(estimateTokens("")).toBe(0)
|
||||||
|
expect(estimateTokens("test")).toBe(1)
|
||||||
|
expect(estimateTokens("12345678")).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should round up", () => {
|
||||||
|
expect(estimateTokens("12345")).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("estimateTokensForLines", () => {
|
||||||
|
it("should estimate tokens for array of lines", () => {
|
||||||
|
const lines = ["line1", "line2"]
|
||||||
|
const expected = estimateTokens("line1\nline2")
|
||||||
|
|
||||||
|
expect(estimateTokensForLines(lines)).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty array", () => {
|
||||||
|
expect(estimateTokensForLines([])).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("truncateToTokens", () => {
|
||||||
|
it("should not truncate short text", () => {
|
||||||
|
const text = "short"
|
||||||
|
expect(truncateToTokens(text, 10)).toBe(text)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should truncate long text", () => {
|
||||||
|
const text = "a".repeat(100)
|
||||||
|
const result = truncateToTokens(text, 10)
|
||||||
|
|
||||||
|
expect(result).toBe("a".repeat(40) + "...")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("formatTokenCount", () => {
|
||||||
|
it("should format small numbers as-is", () => {
|
||||||
|
expect(formatTokenCount(500)).toBe("500")
|
||||||
|
expect(formatTokenCount(999)).toBe("999")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should format thousands with k suffix", () => {
|
||||||
|
expect(formatTokenCount(1000)).toBe("1.0k")
|
||||||
|
expect(formatTokenCount(1500)).toBe("1.5k")
|
||||||
|
expect(formatTokenCount(12345)).toBe("12.3k")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
21
packages/ipuaro/tsconfig.json
Normal file
21
packages/ipuaro/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"target": "ES2023",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolvePackageJsonExports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "tests"]
|
||||||
|
}
|
||||||
19
packages/ipuaro/tsup.config.ts
Normal file
19
packages/ipuaro/tsup.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from "tsup"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts", "src/cli/index.ts"],
|
||||||
|
format: ["esm"],
|
||||||
|
dts: true,
|
||||||
|
clean: true,
|
||||||
|
sourcemap: true,
|
||||||
|
splitting: false,
|
||||||
|
treeshake: true,
|
||||||
|
external: [
|
||||||
|
"tree-sitter",
|
||||||
|
"tree-sitter-typescript",
|
||||||
|
"tree-sitter-javascript",
|
||||||
|
],
|
||||||
|
esbuildOptions(options) {
|
||||||
|
options.jsx = "automatic"
|
||||||
|
},
|
||||||
|
})
|
||||||
25
packages/ipuaro/vitest.config.ts
Normal file
25
packages/ipuaro/vitest.config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from "vitest/config"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "node",
|
||||||
|
include: ["tests/**/*.test.ts"],
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "html", "lcov"],
|
||||||
|
include: ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
exclude: [
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/index.ts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
],
|
||||||
|
thresholds: {
|
||||||
|
lines: 80,
|
||||||
|
functions: 80,
|
||||||
|
branches: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
1652
pnpm-lock.yaml
generated
1652
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user