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:
imfozilbek
2025-11-29 23:08:38 +05:00
parent 7f6180df37
commit 130a8c4f24
62 changed files with 4629 additions and 6 deletions

View File

@@ -0,0 +1,2 @@
// Config module exports
export * from "./loader.js"

View 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}`)
}

View 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({})

View File

@@ -0,0 +1,3 @@
// Shared constants
export * from "./config.js"
export * from "./messages.js"

View 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

View 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")
}
}

View File

@@ -0,0 +1,2 @@
// Shared errors
export * from "./IpuaroError.js"

View 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"

View 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
}

View 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)
}

View File

@@ -0,0 +1,3 @@
// Shared utilities
export * from "./hash.js"
export * from "./tokens.js"

View 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()
}