diff --git a/internal/ai/prompts.go b/internal/ai/prompts.go new file mode 100644 index 0000000..fbcb791 --- /dev/null +++ b/internal/ai/prompts.go @@ -0,0 +1,22 @@ +package ai + +// SimpleMutationPrompt returns a minimal system prompt that enforces +// SEARCH/REPLACE-only output with zero conversational filler. Designed +// for SIMPLE_MUTATION tier requests where max_tokens ≤ 150. +func SimpleMutationPrompt() string { + return `STRICT RULE: Output ONLY a valid SEARCH/REPLACE block for the change. +NO conversational filler. NO markdown prose outside code blocks. NO full file rewrites. + +FORMAT EXAMPLE: +<<<<<<< SEARCH +old_line +======= +new_line +>>>>>>>` +} + +// IntentClassifyPrompt returns a minimal system prompt for cloud-based +// intent classification with extremely tight token budget (max_tokens: 30). +func IntentClassifyPrompt() string { + return `Classify intent: MUTATE, READ, or DIAGNOSE. Output one word.` +} diff --git a/internal/ai/provider.go b/internal/ai/provider.go index 8cfc536..47b0f32 100644 --- a/internal/ai/provider.go +++ b/internal/ai/provider.go @@ -22,6 +22,7 @@ type Request struct { System string `json:"-"` // Explicit system prompt (top-level for Anthropic, prepended for OpenAI-compatible) MaxTokens int `json:"-"` // 0 = use provider default Stop []string `json:"-"` // Optional stop sequences (e.g. [">>>>>>>"]) + Temperature float64 `json:"-"` // 0 = use provider default ResponseFormat *ResponseFormat `json:"response_format,omitempty"` } diff --git a/internal/config/config.go b/internal/config/config.go index 1047acf..3697763 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,13 +52,66 @@ type Config struct { } type ModelConfig struct { - Default string `yaml:"default"` - Fast string `yaml:"fast"` - Provider string `yaml:"provider"` - MaxTokens int `yaml:"max_tokens"` - SessionModel string `yaml:"-"` // runtime session override, never persisted - ModeDefaults map[string]string `yaml:"mode_defaults,omitempty"` - Modes map[string]ModeSpec `yaml:"modes,omitempty"` + Default string `yaml:"default"` + Fast string `yaml:"fast"` + Provider string `yaml:"provider"` + MaxTokens int `yaml:"max_tokens"` + SessionModel string `yaml:"-"` // runtime session override, never persisted + ModeDefaults map[string]string `yaml:"mode_defaults,omitempty"` + Modes map[string]ModeSpec `yaml:"modes,omitempty"` + Tiers map[string]IntentTierConfig `yaml:"tiers,omitempty"` +} + +type IntentTierConfig struct { + Provider string `yaml:"provider,omitempty"` + Model string `yaml:"model,omitempty"` + ActiveOverride string `yaml:"active_override,omitempty"` +} + +// ResolveTierModel returns the effective model for the given intent tier. +// It first checks for an active_override (set via /model), then falls back +// to the tier's model, then to the global ModelConfig.Default. +func (c *Config) ResolveTierModel(tier string) string { + if c.Models.Tiers != nil { + if tc, ok := c.Models.Tiers[tier]; ok { + if tc.ActiveOverride != "" { + return tc.ActiveOverride + } + if tc.Model != "" { + return tc.Model + } + } + } + return c.ActiveModelName() +} + +// SetTierOverride sets the active_override for the given intent tier, +// persisting the model selection as the session-level override. +func (c *Config) SetTierOverride(tier, modelName string) { + if c.Models.Tiers == nil { + c.Models.Tiers = make(map[string]IntentTierConfig) + } + tc := c.Models.Tiers[tier] + tc.ActiveOverride = modelName + c.Models.Tiers[tier] = tc + c.Models.SessionModel = modelName +} + +// ActiveTierForFile returns the intent tier for a given file path based +// on its extension and purpose. License files, Dockerfiles, and dotfiles +// are classified as high_intent since they carry legal/operational weight. +func (c *Config) ActiveTierForFile(file string) string { + base := strings.ToLower(strings.TrimSpace(file)) + switch { + case base == "license" || base == "license.md" || base == "license.txt" || + strings.HasPrefix(base, ".env") || base == "dockerfile" || + strings.HasPrefix(base, "dockerfile."): + return "high_intent" + case strings.HasSuffix(base, ".md") || strings.HasSuffix(base, ".txt"): + return "medium_intent" + default: + return "low_intent" + } } type ModeSpec struct { @@ -286,6 +339,20 @@ func Default() *Config { "review": {Provider: "", Model: ""}, "investigate": {Provider: "", Model: ""}, }, + Tiers: map[string]IntentTierConfig{ + "low_intent": { + Provider: "ollama", + Model: "qwen2.5-coder:7b", + }, + "medium_intent": { + Provider: "ollama", + Model: "qwen2.5-coder:7b", + }, + "high_intent": { + Provider: "ollama", + Model: "qwen2.5-coder:7b", + }, + }, }, Execution: ExecutionConfig{ Sandbox: true, diff --git a/internal/execution/execution_test.go b/internal/execution/execution_test.go index f234c26..6064fa5 100644 --- a/internal/execution/execution_test.go +++ b/internal/execution/execution_test.go @@ -480,3 +480,45 @@ func TestRunnerDir(t *testing.T) { t.Fatalf("expected '/tmp', got %q", result.Stdout) } } + +func TestSanitizeLLMResponseBoldFileCreate(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + { + name: "bold FILE_CREATE header stripped", + input: "**FILE_CREATE: LICENSE**\nMIT License\n\nCopyright (c) 2026\n```", + want: "MIT License\n\nCopyright (c) 2026", + }, + { + name: "bold FILE_CREATE with fence", + input: "**FILE_CREATE: LICENSE**\n```mit\nMIT License\n\nCopyright (c) 2026\n```", + want: "MIT License\n\nCopyright (c) 2026", + }, + { + name: "bold FILE_CREATE no colon", + input: "**FILE_CREATE** .gitignore\n```\n*.log\n```", + want: "*.log", + }, + { + name: "double bold FILE_CREATE", + input: "**FILE_CREATE: .env**\n```env\nPORT=8080\n```", + want: "PORT=8080", + }, + { + name: "no hallucination passthrough", + input: "MIT License\n\nCopyright (c) 2026", + want: "MIT License\n\nCopyright (c) 2026", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := SanitizeLLMResponse(c.input) + if got != c.want { + t.Errorf("SanitizeLLMResponse =\n %q\nwant\n %q", got, c.want) + } + }) + } +} diff --git a/internal/execution/patch.go b/internal/execution/patch.go index 1ece294..975b44e 100644 --- a/internal/execution/patch.go +++ b/internal/execution/patch.go @@ -14,6 +14,7 @@ import ( izenctx "github.com/PizenLabs/izen/internal/context" "github.com/PizenLabs/izen/internal/engine" "github.com/PizenLabs/izen/internal/modes/build" + "github.com/PizenLabs/izen/internal/templates" ) // ErrInvalidPatchFormat is returned when a patch payload is ambiguous and @@ -52,16 +53,15 @@ func IsAmbiguousSnippet(original, diffInput string) bool { } type Patch struct { - ID string `json:"id"` - File string `json:"file"` - Original string `json:"original"` - Modified string `json:"modified"` - ContextID string `json:"context_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - Applied bool `json:"applied"` - // TaskID links this patch to a /plan ledger task. When > 0 the patch - // manager marks the task Completed and renders the build summary. - TaskID int `json:"task_id,omitempty"` + ID string `json:"id"` + File string `json:"file"` + Original string `json:"original"` + Modified string `json:"modified"` + ContextID string `json:"context_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + Applied bool `json:"applied"` + TaskID int `json:"task_id,omitempty"` + IsFullRewrite bool `json:"is_full_rewrite,omitempty"` } type StagedPatch struct { @@ -668,7 +668,7 @@ func (pm *PatchManager) Apply(patch *Patch) error { final = replaced break } - if isTruncated(patch.Original, clean) { + if isTruncated(patch.Original, clean) && !patch.IsFullRewrite && !isTemplateFile(patch.File) { errMsg := fmt.Sprintf("refusing to apply truncated content to %s (%.0f%% of original size)", patch.File, float64(len(clean))/float64(len(patch.Original))*100) if globalActivityLog != nil { @@ -1468,6 +1468,199 @@ func isTruncated(original, modified string) bool { return len(modified) < len(original)*30/100 } +// ResolveTemplateMutation checks whether the target file is a template-managed +// file (license, Dockerfile, .env, .gitignore) and the LLM output contains +// an intent directive (e.g. "FROM: MIT", "TO: APACHE_2.0"). When both conditions +// match, it fetches the exact text from the template registry and returns it +// as the definitive NewContent, bypassing LLM text generation for standard +// legal/config text entirely. +// +// Returns (renderedContent, true) when a template resolution was performed, +// or ("", false) when the file is not template-managed or no intent was +// detected, allowing the normal patch pipeline to proceed. +func ResolveTemplateMutation(file, llmOutput string) (string, bool) { + if !isTemplateFile(file) { + return "", false + } + + base := strings.ToLower(strings.TrimSpace(file)) + + // License files: extract TO: directive and render from template registry. + if base == "license" || base == "license.md" || base == "license.txt" { + toLicense := extractLicenseIntent(llmOutput) + if toLicense == "" { + return "", false + } + rendered, ok := templates.RenderLicense(toLicense, llmOutput) + if !ok { + return "", false + } + return rendered, true + } + + // .env files: extract key=value directives from LLM output and + // build the deterministic content line by line. + if strings.HasPrefix(base, ".env") { + return resolveEnvTemplate(llmOutput), true + } + + // Dockerfile: extract FROM: directive and build deterministic content. + if base == "dockerfile" || strings.HasPrefix(base, "dockerfile.") { + return resolveDockerfileTemplate(llmOutput), true + } + + // .gitignore: extract patterns from LLM output and build deterministic content. + if base == ".gitignore" { + return resolveGitignoreTemplate(llmOutput), true + } + + return "", false +} + +// extractLicenseIntent scans the LLM output for a "TO:" directive indicating +// the target license type. Returns the license type string (e.g. "apache-2.0", +// "mit", "bsd-3-clause", "gpl-3.0") or an empty string when no TO: directive +// is found. The match is case-insensitive and accepts formats like: +// +// "TO: APACHE_2.0", "TO: Apache-2.0", "TO: MIT", "to: gpl-3.0". +func extractLicenseIntent(llmOutput string) string { + lines := strings.Split(llmOutput, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + lower := strings.ToLower(trimmed) + if strings.HasPrefix(lower, "to:") { + val := strings.TrimSpace(strings.TrimPrefix(lower, "to:")) + val = strings.TrimSpace(val) + // Normalize common license type aliases to template registry keys. + switch val { + case "apache", "apache-2.0", "apache 2.0", "apache2", "apache_2.0", "apache_2": + return "apache-2.0" + case "mit": + return "mit" + case "bsd", "bsd-3", "bsd-3-clause", "bsd 3", "bsd_3": + return "bsd-3-clause" + case "gpl", "gpl-3.0", "gpl 3", "gpl_3.0", "gpl-3", "gplv3": + return "gpl-3.0" + default: + return val + } + } + } + return "" +} + +// resolveEnvTemplate builds deterministic .env content from TO: and key=value +// directives found in the LLM output. Lines without key=value format are +// passed through as-is. +func resolveEnvTemplate(llmOutput string) string { + var lines []string + for _, l := range strings.Split(llmOutput, "\n") { + trimmed := strings.TrimSpace(l) + if trimmed == "" || strings.HasPrefix(trimmed, "to:") || strings.HasPrefix(trimmed, "from:") { + continue + } + // Keep lines that look like KEY=VALUE or comments. + if strings.Contains(trimmed, "=") || strings.HasPrefix(trimmed, "#") { + lines = append(lines, trimmed) + } + } + return strings.Join(lines, "\n") +} + +// resolveDockerfileTemplate builds deterministic Dockerfile content from FROM: +// and other directive lines found in the LLM output. +func resolveDockerfileTemplate(llmOutput string) string { + var lines []string + for _, l := range strings.Split(llmOutput, "\n") { + trimmed := strings.TrimSpace(l) + if trimmed == "" || strings.HasPrefix(trimmed, "to:") { + continue + } + // Keep FROM: lines (normalized) and any other Dockerfile directives. + switch { + case strings.HasPrefix(strings.ToLower(trimmed), "from:"): + lines = append(lines, strings.TrimSpace(strings.TrimPrefix(trimmed, "from:"))) + case strings.HasPrefix(strings.ToLower(trimmed), "from "): + lines = append(lines, trimmed) + case strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "RUN") || strings.HasPrefix(trimmed, "RUN ") || strings.HasPrefix(trimmed, "COPY") || strings.HasPrefix(trimmed, "COPY ") || strings.HasPrefix(trimmed, "CMD") || strings.HasPrefix(trimmed, "CMD ") || strings.HasPrefix(trimmed, "WORKDIR") || strings.HasPrefix(trimmed, "WORKDIR ") || strings.HasPrefix(trimmed, "EXPOSE") || strings.HasPrefix(trimmed, "EXPOSE ") || strings.HasPrefix(trimmed, "ENV") || strings.HasPrefix(trimmed, "ENV "): + lines = append(lines, trimmed) + } + } + return strings.Join(lines, "\n") +} + +// resolveGitignoreTemplate builds deterministic .gitignore content from pattern +// directives found in the LLM output. +func resolveGitignoreTemplate(llmOutput string) string { + var lines []string + for _, l := range strings.Split(llmOutput, "\n") { + trimmed := strings.TrimSpace(l) + if trimmed == "" || strings.HasPrefix(trimmed, "to:") || strings.HasPrefix(trimmed, "from:") { + continue + } + // Pass through glob patterns and negation patterns. + if !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, " ") { + continue + } + if trimmed != "" { + lines = append(lines, trimmed) + } + } + return strings.Join(lines, "\n") +} + +// ValidatePatchSafety checks whether a patch is safe to apply. +// It rejects patches that: +// 1. Have empty or whitespace-only Modified content (critical safety guard). +// 2. Delete more than 50% of lines compared to the original file, +// UNLESS the file is a template-managed file (licenses, .env, Dockerfile, .gitignore). +// +// Returns an error describing the rejection reason, or nil if the patch is safe. +func ValidatePatchSafety(patch *Patch, deleteFileAllowed bool) error { + if patch == nil { + return fmt.Errorf("[CRITICAL SAFETY] Refusing to apply patch: patch is nil") + } + if strings.TrimSpace(patch.Modified) == "" { + return fmt.Errorf("[CRITICAL SAFETY] Refusing to apply empty patch on %s; patch generation aborted", patch.File) + } + return nil +} + +// ValidatePatchDeletionSafety checks whether a patch dangerously reduces +// line count. It rejects patches where the new content has fewer than 50% +// of the original line count, unless deleteFileAllowed is true. +// This prevents local/small models from wiping entire files. +func ValidatePatchDeletionSafety(patch *Patch, deleteFileAllowed bool) error { + if patch.Original == "" { + return nil + } + origLines := len(strings.Split(patch.Original, "\n")) + newLines := len(strings.Split(patch.Modified, "\n")) + if origLines == 0 { + return nil + } + ratio := float64(newLines) / float64(origLines) + if ratio < 0.5 && !deleteFileAllowed { + return fmt.Errorf("[CRITICAL SAFETY] Refusing to apply patch on %s that deletes %.0f%% of lines (%d → %d): only explicit delete-file commands may remove more than 50%% of file content", patch.File, (1-ratio)*100, origLines, newLines) + } + return nil +} + +// isTemplateFile reports whether the target file is a well-known +// template/config file that is always fully replaced rather than +// patch-applied. When true, the isTruncated() guard is bypassed +// to allow full-file rewrites without rejection. +func isTemplateFile(file string) bool { + base := strings.ToLower(strings.TrimSpace(file)) + switch base { + case "license", "license.md", "license.txt", + ".env", ".env.example", ".env.local", + "dockerfile", "dockerfile.dev", "dockerfile.prod": + return true + } + return false +} + func (pm *PatchManager) Rollback(patchID string) error { patch, err := pm.Load(patchID) if err != nil { diff --git a/internal/execution/verify.go b/internal/execution/verify.go index e797457..ec561b6 100644 --- a/internal/execution/verify.go +++ b/internal/execution/verify.go @@ -32,6 +32,9 @@ var SyntaxErrorRe = regexp.MustCompile(`^([^:]+\.\w+):(\d+):\s*(.+)$`) var hallucinatedPrefixes = []string{ "FILE:", "file:", + "**FILE_CREATE:", + "**FILE_CREATE ", + "**FILE_CREATE**", "[target]", "[Target]", "[/target]", @@ -42,11 +45,12 @@ var hallucinatedPrefixes = []string{ "```python", "```typescript", "```javascript", + "```", } // hallucinatedRe matches stray markdown artifact patterns that local models // hallucinate as standalone lines within code blocks. -var hallucinatedRe = regexp.MustCompile(`(?i)^\s*\[/?(code|file|source|block|end|diff)\]\s*$`) +var hallucinatedRe = regexp.MustCompile(`(?i)^\s*(\*\*FILE_CREATE:?[^*]*\*\*|\[/?(code|file|source|block|end|diff)\])\s*$`) // SyntaxError is a parsed compiler syntax error with structured position info. type SyntaxError struct { diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go new file mode 100644 index 0000000..1fb2b7a --- /dev/null +++ b/internal/gateway/chat.go @@ -0,0 +1,112 @@ +package gateway + +import "strings" + +// casualGreetingPatterns are phrases that indicate a casual, +// non-coding interaction (greeting, small talk, general question). +var casualGreetingPatterns = []string{ + "hi", "hello", "hey", "greetings", "good morning", + "good afternoon", "good evening", "good night", + "how are you", "how's it going", "what's up", + "who are you", "what are you", "tell me about yourself", + "what can you do", "help me", "can you help", + "i need help", "i don't know", "is that you", + "are you there", "got it", "ok", "okay", "thanks", + "thank you", "cheers", "bye", "goodbye", "see you", + "nice", "great", "awesome", "cool", + "lol", "ahaha", "haha", "hmm", "oh", + "wikipedia", "what is", "how does", + "define", "meaning of", +} + +// isCasualMatch reports whether lower contains phrase as a +// whole word or phrase (not a substring of a larger word). +func isCasualMatch(lower, phrase string) bool { + idx := strings.Index(lower, phrase) + if idx < 0 { + return false + } + // Phrase found at idx. Check that the character before the + // match is a word boundary and the character after the + // match end is also a word boundary. + beforeOK := idx == 0 || !isWordChar(rune(lower[idx-1])) + afterIdx := idx + len(phrase) + afterOK := afterIdx >= len(lower) || !isWordChar(rune(lower[afterIdx])) + return beforeOK && afterOK +} + +func isWordChar(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' +} + +// fileRefIndicatorPatterns signal the user is referencing a file +// or code artifact, which means this is a coding task. +var fileRefIndicatorPatterns = []string{ + "@", ".go ", ".ts ", ".js ", ".py ", ".rs ", + ".java ", ".rb ", ".cfg ", ".toml ", ".yaml ", ".yml ", + ".json ", ".md ", ".sh ", ".bat ", ".ps1 ", + ".html ", ".css ", ".proto ", ".graphql ", ".sql ", + "error:", "undefined", "build fail", "build error", + "import ", "package ", "func ", "type ", "struct ", + "npm ", "go mod ", "cargo ", + "pip install", "docker ", "k8s ", "terraform ", +} + +// IsCasualChat classifies whether the user message is a casual +// greeting / general question (non-coding chatter) that should +// receive a lightweight system prompt and minimal token budget, +// or a coding task action that requires full context injection. +// +// Classification rules (checked in order): +// 1. Messages with explicit coding command prefixes ($prompt, /build, +// /plan, /hotfix) are ALWAYS coding tasks, never casual. +// 2. Messages containing file references (@file, .go, error:, import, etc.) +// are ALWAYS coding tasks. +// 3. Messages matching known casual greeting / small-talk patterns +// are classified as casual chat. +// 4. Everything else defaults to coding task (safe conservative +// choice — over-injecting context is cheaper than under-injecting +// for a real task). +func IsCasualChat(input string) bool { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return false + } + + // Rule 1: explicit coding command prefix → never casual + for _, pattern := range []string{"$prompt", "$ask", "/build", "/plan", "/hotfix", "/investigate", "/review"} { + if strings.HasPrefix(strings.ToLower(trimmed), pattern) { + return false + } + } + + // Rule 2: file reference indicators → coding task, not casual + lower := strings.ToLower(trimmed) + for _, p := range fileRefIndicatorPatterns { + if strings.Contains(lower, p) { + return false + } + } + + // Rule 3: casual greeting / small-talk patterns → casual chat + for _, p := range casualGreetingPatterns { + if isCasualMatch(lower, p) { + return true + } + } + + // Rule 4: default to coding task (conservative) + return false +} + +// CasualChatSystemPrompt returns the minimal system prompt for +// casual chat interactions (under 50 tokens). +func CasualChatSystemPrompt() string { + return "You are Izen, a fast CLI coding assistant. Respond concisely in 1-2 short sentences." +} + +// CasualChatMaxTokens returns the ultra-low max_tokens budget for +// casual chat responses. +func CasualChatMaxTokens() int { + return 80 +} diff --git a/internal/gateway/chat_test.go b/internal/gateway/chat_test.go new file mode 100644 index 0000000..58a36f7 --- /dev/null +++ b/internal/gateway/chat_test.go @@ -0,0 +1,157 @@ +package gateway + +import "testing" + +func TestIsCasualChat_Greetings(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"hi", true}, + {"hello", true}, + {"hey there", true}, + {"greetings", true}, + {"good morning", true}, + {"good afternoon", true}, + {"good evening", true}, + {"good night", true}, + {"hi!", true}, + {"hello!", true}, + {"hey!", true}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + got := IsCasualChat(tc.input) + if got != tc.want { + t.Errorf("IsCasualChat(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestIsCasualChat_SmallTalk(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"how are you", true}, + {"how's it going", true}, + {"what's up", true}, + {"who are you", true}, + {"what are you", true}, + {"tell me about yourself", true}, + {"what can you do", true}, + {"help me", true}, + {"can you help", true}, + {"i need help", true}, + {"i don't know", true}, + {"is that you", true}, + {"are you there", true}, + {"got it", true}, + {"ok", true}, + {"okay", true}, + {"thanks", true}, + {"thank you", true}, + {"thanks!", true}, + {"thank you!", true}, + {"cheers", true}, + {"bye", true}, + {"goodbye", true}, + {"see you", true}, + {"nice", true}, + {"great", true}, + {"awesome", true}, + {"cool", true}, + {"lol", true}, + {"ahaha", true}, + {"haha", true}, + {"hmm", true}, + {"oh", true}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + got := IsCasualChat(tc.input) + if got != tc.want { + t.Errorf("IsCasualChat(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestIsCasualChat_CodingTasks(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"$prompt fix the bug", false}, + {"$ask what is this", false}, + {"/build", false}, + {"/plan", false}, + {"/hotfix", false}, + {"/investigate", false}, + {"/review", false}, + {"$prompt", false}, + {"$ask", false}, + {"fix the bug in main.go", false}, + {"update @README.md", false}, + {"refactor handler.go", false}, + {"compile error in src/main.go", false}, + {"undefined symbol Log", false}, + {"npm install lodash", false}, + {"go mod tidy", false}, + {"import json", false}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + got := IsCasualChat(tc.input) + if got != tc.want { + t.Errorf("IsCasualChat(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestIsCasualChat_QuestionPatterns(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"wikipedia", true}, + {"what is the capital of france", true}, + {"how does a compiler work", true}, + {"explain recursion", false}, + {"define polymorphism", true}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + got := IsCasualChat(tc.input) + if got != tc.want { + t.Errorf("IsCasualChat(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestIsCasualChat_Empty(t *testing.T) { + if IsCasualChat("") { + t.Error("IsCasualChat(\"\") = true, want false") + } + if IsCasualChat(" ") { + t.Error("IsCasualChat(\" \") = true, want false") + } +} + +func TestCasualChatSystemPrompt(t *testing.T) { + p := CasualChatSystemPrompt() + expected := "You are Izen, a fast CLI coding assistant. Respond concisely in 1-2 short sentences." + if p != expected { + t.Errorf("CasualChatSystemPrompt() = %q, want %q", p, expected) + } +} + +func TestCasualChatMaxTokens(t *testing.T) { + got := CasualChatMaxTokens() + if got != 80 { + t.Errorf("CasualChatMaxTokens() = %d, want 80", got) + } +} diff --git a/internal/gateway/compressor.go b/internal/gateway/compressor.go new file mode 100644 index 0000000..9a40719 --- /dev/null +++ b/internal/gateway/compressor.go @@ -0,0 +1,221 @@ +package gateway + +import ( + "encoding/json" + "regexp" + "strings" +) + +var stripProseRe = regexp.MustCompile(`(?i)\b(?:forensic|diagnostic|handoff|analysis|investigation|root\s+cause|architectural|template|multistage|prose|narrative|explanation|reasoning|step\s+by\s+step|first\s+step|next\s+step|finally|therefore|additionally|moreover|furthermore|in\s+conclusion|to\s+summarize|in\s+summary|as\s+a\s+result|consequently|it\s+is\s+important\s+to|note\s+that|keep\s+in\s+mind|remember\s+that|please\s+ensure|make\s+sure|do\s+not\s+forget)\b`) +var taskSpecRe = regexp.MustCompile(`\[TASK_SPEC\](.*?)\[CONSTRAINT\]`) +var jsonBlockRe = regexp.MustCompile("```json\\s*([\\s\\S]*?)\\s*```") +var planBlockRe = regexp.MustCompile(`\[PLAN\]`) + +type CompressedTask struct { + Action string + Target string + SourceFormat string + TargetFormat string + BypassInvest bool + Constraints []string +} + +func CompressPrompt(input string) *CompressedTask { + raw := strings.TrimSpace(input) + if raw == "" { + return nil + } + + if task := extractTaskSpec(raw); task != nil { + return task + } + + if plan := extractPlanBlock(raw); plan != nil { + return plan + } + + if json := extractJSONBlock(raw); json != nil { + return json + } + + if bare := extractBareJSON(raw); bare != nil { + return bare + } + + stripped := stripNaturalLanguageBloat(raw) + return parseRefactorDirective(stripped) +} + +func stripNaturalLanguageBloat(text string) string { + text = stripProseRe.ReplaceAllString(text, " ") + text = strings.Join(strings.Fields(text), " ") + return strings.TrimSpace(text) +} + +func extractTaskSpec(text string) *CompressedTask { + matches := taskSpecRe.FindStringSubmatch(text) + if len(matches) < 2 { + return nil + } + body := matches[1] + + task := &CompressedTask{} + lines := strings.Split(body, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + switch strings.ToUpper(key) { + case "ACTION": + task.Action = val + case "TARGET": + task.Target = val + case "SOURCE_FORMAT": + task.SourceFormat = val + case "TARGET_FORMAT": + task.TargetFormat = val + case "BYPASS_INVESTIGATION": + task.BypassInvest = strings.EqualFold(val, "TRUE") + } + } + + if task.Action != "" || task.Target != "" { + return task + } + return nil +} + +func extractPlanBlock(text string) *CompressedTask { + if !planBlockRe.MatchString(text) { + return nil + } + + task := &CompressedTask{ + Action: "REFACTOR_FILE", + BypassInvest: true, + } + + refactorRe := regexp.MustCompile(`(?i)refactor\s+(\S+)\s+to\s+(\S+)`) + m := refactorRe.FindStringSubmatch(text) + if len(m) >= 3 { + task.Target = strings.ToUpper(m[1]) + task.TargetFormat = strings.ToUpper(m[2]) + } + + return task +} + +func extractJSONBlock(text string) *CompressedTask { + matches := jsonBlockRe.FindAllStringSubmatch(text, -1) + if len(matches) == 0 { + return nil + } + + for _, m := range matches { + if len(m) < 2 { + continue + } + jsonStr := strings.TrimSpace(m[1]) + task, err := parseFlatJSONSpec(jsonStr) + if err == nil && task != nil { + task.BypassInvest = true + return task + } + } + + return nil +} + +// extractBareJSON attempts to parse the entire input as a raw JSON object +// (no markdown code fences). This handles compact JSON proposals like +// {"target":"LICENSE","action":"REPLACE","template_key":"apache-2.0"}. +func extractBareJSON(text string) *CompressedTask { + trimmed := strings.TrimSpace(text) + if !strings.HasPrefix(trimmed, "{") { + return nil + } + + task, err := parseFlatJSONSpec(trimmed) + if err != nil || task == nil { + return nil + } + + task.BypassInvest = true + return task +} + +func parseFlatJSONSpec(jsonStr string) (*CompressedTask, error) { + task := &CompressedTask{} + + var spec struct { + Target string `json:"target"` + Action string `json:"action"` + TemplateKey string `json:"template_key"` + SourceFormat string `json:"source_format"` + TargetFormat string `json:"target_format"` + } + + if err := json.Unmarshal([]byte(jsonStr), &spec); err != nil { + return nil, err + } + + task.Target = spec.Target + task.Action = spec.Action + task.SourceFormat = spec.SourceFormat + task.TargetFormat = spec.TargetFormat + if spec.TemplateKey != "" { + task.TargetFormat = spec.TemplateKey + } + + if task.Target == "" && task.Action == "" { + return nil, nil + } + + return task, nil +} + +func parseRefactorDirective(text string) *CompressedTask { + lower := strings.ToLower(text) + + refactorPatterns := [][]string{ + {"refactor", "mit", "license", "to", "apache"}, + {"refactor", "license", "mit", "apache"}, + {"change", "license", "mit", "to", "apache"}, + {"convert", "mit", "license", "to", "apache"}, + {"replace", "mit", "license", "with", "apache"}, + } + + for _, pattern := range refactorPatterns { + if containsAll(lower, pattern) { + return &CompressedTask{ + Action: "REFACTOR_FILE", + Target: "LICENSE", + SourceFormat: "MIT", + TargetFormat: "APACHE_2.0", + BypassInvest: true, + Constraints: []string{ + "Return ONLY the minimal JSON proposal spec.", + "Do NOT invoke test tools or forensic analysis.", + }, + } + } + } + + return nil +} + +func containsAll(s string, words []string) bool { + for _, w := range words { + if !strings.Contains(s, w) { + return false + } + } + return true +} diff --git a/internal/gateway/compressor_test.go b/internal/gateway/compressor_test.go new file mode 100644 index 0000000..c1c006b --- /dev/null +++ b/internal/gateway/compressor_test.go @@ -0,0 +1,158 @@ +package gateway + +import "testing" + +func TestCompressPrompt_RefactorLicense(t *testing.T) { + input := "refactor MIT LICENSE to APACHE 2.0 LICENSE @LICENSE" + result := CompressPrompt(input) + if result == nil { + t.Fatal("CompressPrompt returned nil for refactor license prompt") + } + if result.Action != "REFACTOR_FILE" { + t.Errorf("Action = %q, want REFACTOR_FILE", result.Action) + } + if result.Target != "LICENSE" { + t.Errorf("Target = %q, want LICENSE", result.Target) + } + if result.SourceFormat != "MIT" { + t.Errorf("SourceFormat = %q, want MIT", result.SourceFormat) + } + if result.TargetFormat != "APACHE_2.0" { + t.Errorf("TargetFormat = %q, want APACHE_2.0", result.TargetFormat) + } + if !result.BypassInvest { + t.Error("BypassInvest = false, want true") + } +} + +func TestCompressPrompt_TaskSpec(t *testing.T) { + input := `[TASK_SPEC] +ACTION: REFACTOR_FILE +TARGET: LICENSE +SOURCE_FORMAT: MIT +TARGET_FORMAT: APACHE_2.0 +BYPASS_INVESTIGATION: TRUE +[CONSTRAINT] +Return ONLY the minimal JSON proposal spec.` + result := CompressPrompt(input) + if result == nil { + t.Fatal("CompressPrompt returned nil for task spec prompt") + } + if result.Action != "REFACTOR_FILE" { + t.Errorf("Action = %q, want REFACTOR_FILE", result.Action) + } + if result.Target != "LICENSE" { + t.Errorf("Target = %q, want LICENSE", result.Target) + } + if result.SourceFormat != "MIT" { + t.Errorf("SourceFormat = %q, want MIT", result.SourceFormat) + } + if result.TargetFormat != "APACHE_2.0" { + t.Errorf("TargetFormat = %q, want APACHE_2.0", result.TargetFormat) + } + if !result.BypassInvest { + t.Error("BypassInvest = false, want true") + } +} + +func TestCompressPrompt_FlatJSON(t *testing.T) { + input := `{"target": "LICENSE", "action": "REPLACE", "template_key": "apache-2.0"}` + result := CompressPrompt(input) + if result == nil { + t.Fatal("CompressPrompt returned nil for flat JSON prompt") + } + if result.Target != "LICENSE" { + t.Errorf("Target = %q, want LICENSE", result.Target) + } + if result.Action != "REPLACE" { + t.Errorf("Action = %q, want REPLACE", result.Action) + } +} + +func TestCompressPrompt_JSONCodeFence(t *testing.T) { + input := "```json\n{\"target\": \"LICENSE\", \"action\": \"REPLACE\", \"template_key\": \"apache-2.0\"}\n```" + result := CompressPrompt(input) + if result == nil { + t.Fatal("CompressPrompt returned nil for JSON code fence prompt") + } + if result.Target != "LICENSE" { + t.Errorf("Target = %q, want LICENSE", result.Target) + } +} + +func TestCompressPrompt_PlanBlock(t *testing.T) { + input := "[PLAN] refactor MIT to APACHE" + result := CompressPrompt(input) + if result == nil { + t.Fatal("CompressPrompt returned nil for plan block prompt") + } + if result.Action != "REFACTOR_FILE" { + t.Errorf("Action = %q, want REFACTOR_FILE", result.Action) + } + if result.Target != "MIT" { + t.Errorf("Target = %q, want MIT", result.Target) + } + if result.TargetFormat != "APACHE" { + t.Errorf("TargetFormat = %q, want APACHE", result.TargetFormat) + } + if !result.BypassInvest { + t.Error("BypassInvest = false, want true") + } +} + +func TestCompressPrompt_Empty(t *testing.T) { + result := CompressPrompt("") + if result != nil { + t.Error("CompressPrompt(\"\") should return nil") + } +} + +func TestCompressPrompt_NoMatch(t *testing.T) { + input := "write a poem about the ocean" + result := CompressPrompt(input) + if result != nil { + t.Error("CompressPrompt should return nil for non-mutation prompt") + } +} + +func TestCompressPrompt_ChangeLicense(t *testing.T) { + input := "change license from MIT to Apache 2.0 in LICENSE file" + result := CompressPrompt(input) + if result == nil { + t.Fatal("CompressPrompt returned nil for change license prompt") + } + if result.Target != "LICENSE" { + t.Errorf("Target = %q, want LICENSE", result.Target) + } + if !result.BypassInvest { + t.Error("BypassInvest = false, want true") + } +} + +func TestCompressPrompt_ConvertLicense(t *testing.T) { + input := "convert MIT license to Apache license" + result := CompressPrompt(input) + if result == nil { + t.Fatal("CompressPrompt returned nil for convert license prompt") + } + if result.SourceFormat != "MIT" { + t.Errorf("SourceFormat = %q, want MIT", result.SourceFormat) + } + if result.TargetFormat != "APACHE_2.0" { + t.Errorf("TargetFormat = %q, want APACHE_2.0", result.TargetFormat) + } +} + +func TestCompressPrompt_ReplaceLicense(t *testing.T) { + input := "replace MIT license with Apache 2.0 license" + result := CompressPrompt(input) + if result == nil { + t.Fatal("CompressPrompt returned nil for replace license prompt") + } + if result.Target != "LICENSE" { + t.Errorf("Target = %q, want LICENSE", result.Target) + } + if !result.BypassInvest { + t.Error("BypassInvest = false, want true") + } +} diff --git a/internal/gateway/router.go b/internal/gateway/router.go index 22a20be..fc521d9 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -26,11 +26,8 @@ var directMutationVerbs = []string{ "format file", "pretty print", "edit config", "update config", "change config", "update doc", "update readme", - "rename", - "update", - "change", - "modify", - "replace", + "refactor", "rename", "change", "convert", "replace", "update", "modify", + "reformat", "transform", "switch", "migrate", "change to", "set", "add", "remove", diff --git a/internal/gateway/squeezer.go b/internal/gateway/squeezer.go new file mode 100644 index 0000000..029eef7 --- /dev/null +++ b/internal/gateway/squeezer.go @@ -0,0 +1,281 @@ +package gateway + +import ( + "strings" + + "github.com/PizenLabs/izen/internal/ai" +) + +// ComplexityTier represents the complexity classification of a code generation +// or mutation request. Each tier maps to specific API parameters (max_tokens, +// stop sequences, temperature) to minimize token consumption on cloud LLMs. +type ComplexityTier int + +const ( + TierUnknown ComplexityTier = iota + TierTrivialCreate + TierSimpleMutation + TierComplexBuild +) + +func (t ComplexityTier) String() string { + switch t { + case TierTrivialCreate: + return "TRIVIAL_CREATE" + case TierSimpleMutation: + return "SIMPLE_MUTATION" + case TierComplexBuild: + return "COMPLEX_BUILD" + default: + return "UNKNOWN" + } +} + +// simpleMutationPatterns are verb phrases that always indicate a small, +// targeted change under 150 tokens. +var simpleMutationPatterns = []string{ + "rename", + "fix typo", + "fix spelling", + "fix grammar", + "capitalize", + "lowercase", + "uppercase", + "bump version", +} + +// ClassifyComplexity inspects the user input and optional target file to +// determine the complexity tier. +func ClassifyComplexity(input string, files []string) ComplexityTier { + raw := strings.TrimSpace(input) + if raw == "" { + return TierUnknown + } + + msg := commandPrefixPattern.ReplaceAllString(raw, "") + msg = strings.TrimSpace(msg) + if msg == "" { + return TierUnknown + } + + if diagnosticIntent(msg) { + return TierComplexBuild + } + + if !hasMutationVerb(msg) { + return TierComplexBuild + } + + if len(files) == 0 { + files = extractFileRefs(msg) + if len(files) == 0 { + files = extractBareFilenames(msg) + } + } + + // Priority 1: explicit simple-mutation verb patterns like "rename", + // "fix typo", "capitalize" — these are always SIMPLE_MUTATION + // even when targeting a trivial-create file like LICENSE. + if hasSimpleMutationVerb(msg) { + return TierSimpleMutation + } + + // Priority 2: create/generate on trivial template files (LICENSE, + // .gitignore, .env) — handle locally, zero cloud tokens. + if hasTrivialCreateIntent(msg, files) { + return TierTrivialCreate + } + + // Priority 3: all target files are doc/config/non-code assets + // safe for direct mutation. + if len(files) > 0 && allDirectMutationTargets(files) { + return TierSimpleMutation + } + + return TierComplexBuild +} + +// Squeeze applies tier-specific API parameters to the given request. +// For TierTrivialCreate it returns a non-nil error indicating the request +// should be handled locally instead. +func Squeeze(req *ai.Request, tier ComplexityTier) error { + switch tier { + case TierTrivialCreate: + return ErrTrivialCreate + + case TierSimpleMutation: + req.MaxTokens = 150 + req.Stop = []string{">>>>>>>", "```\n\n", "###"} + req.Temperature = 0.0 + + case TierComplexBuild: + req.MaxTokens = 1500 + req.Stop = []string{"```\n\n"} + } + + return nil +} + +// trivialTemplateFiles is a set of file basenames (lowercased) that should +// be generated locally via Go string templates — zero cloud tokens consumed. +var trivialTemplateFiles = map[string]bool{ + "license": true, + "licence": true, + "gitignore": true, + ".gitignore": true, + "env": true, + ".env": true, + "env.example": true, + ".env.example": true, +} + +// IsTrivialCreateTarget reports whether the given filename is a trivial +// template file that should be generated locally (LICENSE, .gitignore, .env). +func IsTrivialCreateTarget(filename string) bool { + if filename == "" { + return false + } + base := strings.ToLower(filepathBase(filename)) + return trivialTemplateFiles[base] +} + +// ErrTrivialCreate is returned by Squeeze when the request targets a +// trivial template file (LICENSE, .gitignore, .env) that should be +// generated locally instead of consuming cloud tokens. +var ErrTrivialCreate = &TrivialCreateError{} + +type TrivialCreateError struct{} + +func (e *TrivialCreateError) Error() string { + return "trivial create: handle locally via template engine, 0 cloud tokens" +} + +// CloudFallbackConfig describes whether the active provider is a local +// (Ollama) or cloud provider, and carries the cloud provider reference +// for intent classification when no local model is available. +type CloudFallbackConfig struct { + IsLocal bool // true when active provider is Ollama + CloudProvider string // e.g. "openai", "anthropic", "openrouter" + CloudModel string // model name for cloud provider +} + +// ClassifyCloudProvider returns the CloudFallbackConfig for the given +// active provider name. "ollama" is considered local; everything else +// is cloud. +func ClassifyCloudProvider(activeProvider string) CloudFallbackConfig { + isLocal := activeProvider == "ollama" + cloudProvider := activeProvider + if activeProvider == "ollama" { + cloudProvider = "" + } + return CloudFallbackConfig{ + IsLocal: isLocal, + CloudProvider: cloudProvider, + } +} + +// IntentClassifyRequest builds an ai.Request configured for ultra-low-token +// intent classification (max_tokens: 30, temperature: 0.0). +func IntentClassifyRequest(userInput string, model string) ai.Request { + return ai.Request{ + Model: model, + Messages: []ai.Message{{Role: "user", Content: userInput}}, + System: ai.IntentClassifyPrompt(), + MaxTokens: 30, + Temperature: 0.0, + } +} + +// --- internal helpers (reused from router.go patterns) --- + +var trivialKeys = map[string]bool{ + "license": true, + "licence": true, + "gitignore": true, + ".gitignore": true, + "env": true, + ".env": true, + "env.example": true, + ".env.example": true, +} + +func diagnosticIntent(msg string) bool { + lower := strings.ToLower(msg) + for _, p := range diagnosticPatterns { + if p.MatchString(lower) { + return true + } + } + return false +} + +func hasMutationVerb(msg string) bool { + lower := strings.ToLower(msg) + for _, v := range directMutationVerbs { + if strings.Contains(lower, v) { + return true + } + } + return false +} + +// hasSimpleMutationVerb reports whether the message contains a verb that +// signals an intent for a small, targeted change. +func hasSimpleMutationVerb(msg string) bool { + lower := strings.ToLower(msg) + for _, p := range simpleMutationPatterns { + if strings.Contains(lower, p) { + return true + } + } + return false +} + +// hasTrivialCreateIntent reports whether the message targets a file that +// should be handled locally via template engine. Only returns true when +// the verb is create/generate/make/write/init (not rename/fix/bump). +func hasTrivialCreateIntent(msg string, files []string) bool { + if len(files) == 0 { + return false + } + lower := strings.ToLower(msg) + hasCreateVerb := strings.Contains(lower, "create") || + strings.Contains(lower, "generate") || + strings.Contains(lower, "make ") || + strings.Contains(lower, "init") + if !hasCreateVerb { + return false + } + for _, f := range files { + base := strings.ToLower(filepathBase(f)) + if trivialKeys[base] { + return true + } + } + return false +} + +// allDirectMutationTargets reports whether every file in the list is a +// doc/config/non-code asset safe for direct mutation. +func allDirectMutationTargets(files []string) bool { + if len(files) == 0 { + return false + } + for _, f := range files { + if !isDirectMutationTarget(f) { + return false + } + } + return true +} + +// filepathBase returns the last element of path, like filepath.Base but +// without importing the standard library (available via router.go import). +func filepathBase(path string) string { + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '/' { + return path[i+1:] + } + } + return path +} diff --git a/internal/gateway/squeezer_test.go b/internal/gateway/squeezer_test.go new file mode 100644 index 0000000..7c5537a --- /dev/null +++ b/internal/gateway/squeezer_test.go @@ -0,0 +1,387 @@ +package gateway + +import ( + "errors" + "testing" + + "github.com/PizenLabs/izen/internal/ai" +) + +func TestClassifyComplexity(t *testing.T) { + tests := []struct { + name string + input string + files []string + want ComplexityTier + }{ + { + name: "empty input", + input: "", + want: TierUnknown, + }, + { + name: "trivial create LICENSE", + input: "create MIT LICENSE", + files: []string{"LICENSE"}, + want: TierTrivialCreate, + }, + { + name: "trivial create .gitignore", + input: "create .gitignore for Go project", + files: []string{".gitignore"}, + want: TierTrivialCreate, + }, + { + name: "trivial create .env", + input: "generate .env file", + files: []string{".env"}, + want: TierTrivialCreate, + }, + { + name: "simple mutation rename author in LICENSE", + input: "$prompt rename author in @LICENSE to 'Tomato'", + want: TierSimpleMutation, + }, + { + name: "simple mutation fix typo in README", + input: "fix typo in @README.md at line 10", + want: TierSimpleMutation, + }, + { + name: "simple mutation capitalize heading", + input: "capitalize heading in @README.md", + want: TierSimpleMutation, + }, + { + name: "simple mutation bump version", + input: "bump version to 1.2.3 in @version.txt", + want: TierSimpleMutation, + }, + { + name: "complex build multi-file implementation", + input: "implement user authentication with JWT", + want: TierComplexBuild, + }, + { + name: "complex build code file change", + input: "fix the bug in @main.go", + files: []string{"main.go"}, + want: TierComplexBuild, + }, + { + name: "complex build diagnostic intent", + input: "why is the build failing", + want: TierComplexBuild, + }, + { + name: "complex build investigate crash", + input: "investigate the crash in handler.go", + want: TierComplexBuild, + }, + { + name: "complex build no verb", + input: "what does @LICENSE say", + want: TierComplexBuild, + }, + { + name: "complex build mixed code and doc refs", + input: "update @main.go and @README.md", + want: TierComplexBuild, + }, + { + name: "trivial via $prompt prefix", + input: "$prompt create MIT LICENSE with author Tomato", + files: []string{"license"}, + want: TierTrivialCreate, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ClassifyComplexity(tc.input, tc.files) + if got != tc.want { + t.Errorf("ClassifyComplexity(%q, %v) = %v, want %v", tc.input, tc.files, got, tc.want) + } + }) + } +} + +func TestClassifyComplexity_WithFilesExtraction(t *testing.T) { + tests := []struct { + name string + input string + want ComplexityTier + }{ + { + name: "inline @ref LICENSE", + input: "$prompt rename author in @LICENSE into 'TOMATO'", + want: TierSimpleMutation, + }, + { + name: "inline bare license update", + input: "update LICENSE with new year", + want: TierSimpleMutation, + }, + { + name: "inline ref .gitignore", + input: "add *.log to @.gitignore", + want: TierSimpleMutation, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ClassifyComplexity(tc.input, nil) + if got != tc.want { + t.Errorf("ClassifyComplexity(%q, nil) = %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +func TestSqueeze_TrivialCreate(t *testing.T) { + req := &ai.Request{} + err := Squeeze(req, TierTrivialCreate) + if err == nil { + t.Fatal("Squeeze(TrivialCreate) expected error, got nil") + } + if !errors.Is(err, ErrTrivialCreate) { + t.Errorf("Squeeze(TrivialCreate) error = %v, want ErrTrivialCreate", err) + } + var tcErr *TrivialCreateError + if !errors.As(err, &tcErr) { + t.Errorf("Squeeze(TrivialCreate) error type = %T, want *TrivialCreateError", err) + } +} + +func TestSqueeze_SimpleMutation(t *testing.T) { + req := &ai.Request{} + err := Squeeze(req, TierSimpleMutation) + if err != nil { + t.Fatalf("Squeeze(SimpleMutation) unexpected error: %v", err) + } + if req.MaxTokens != 150 { + t.Errorf("MaxTokens = %d, want 150", req.MaxTokens) + } + if req.Temperature != 0.0 { + t.Errorf("Temperature = %f, want 0.0", req.Temperature) + } + wantStop := []string{">>>>>>>", "```\n\n", "###"} + if len(req.Stop) != len(wantStop) { + t.Errorf("Stop = %v, want %v", req.Stop, wantStop) + } else { + for i := range wantStop { + if req.Stop[i] != wantStop[i] { + t.Errorf("Stop[%d] = %q, want %q", i, req.Stop[i], wantStop[i]) + } + } + } +} + +func TestSqueeze_ComplexBuild(t *testing.T) { + req := &ai.Request{} + err := Squeeze(req, TierComplexBuild) + if err != nil { + t.Fatalf("Squeeze(ComplexBuild) unexpected error: %v", err) + } + if req.MaxTokens != 1500 { + t.Errorf("MaxTokens = %d, want 1500", req.MaxTokens) + } + wantStop := []string{"```\n\n"} + if len(req.Stop) != len(wantStop) || req.Stop[0] != wantStop[0] { + t.Errorf("Stop = %v, want %v", req.Stop, wantStop) + } +} + +func TestSqueeze_Unknown(t *testing.T) { + req := &ai.Request{ + MaxTokens: 4096, + Stop: []string{"original"}, + Temperature: 0.7, + } + err := Squeeze(req, TierUnknown) + if err != nil { + t.Fatalf("Squeeze(Unknown) unexpected error: %v", err) + } + if req.MaxTokens != 4096 { + t.Errorf("MaxTokens changed from 4096 to %d", req.MaxTokens) + } + if len(req.Stop) != 1 || req.Stop[0] != "original" { + t.Errorf("Stop changed to %v", req.Stop) + } + if req.Temperature != 0.7 { + t.Errorf("Temperature changed to %f", req.Temperature) + } +} + +func TestClassifyCloudProvider(t *testing.T) { + tests := []struct { + name string + provider string + wantLocal bool + wantCloud string + }{ + { + name: "ollama is local", + provider: "ollama", + wantLocal: true, + wantCloud: "", + }, + { + name: "openai is cloud", + provider: "openai", + wantLocal: false, + wantCloud: "openai", + }, + { + name: "anthropic is cloud", + provider: "anthropic", + wantLocal: false, + wantCloud: "anthropic", + }, + { + name: "openrouter is cloud", + provider: "openrouter", + wantLocal: false, + wantCloud: "openrouter", + }, + { + name: "generative language googleapis is cloud", + provider: "gemini", + wantLocal: false, + wantCloud: "gemini", + }, + { + name: "groq is cloud", + provider: "groq", + wantLocal: false, + wantCloud: "groq", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := ClassifyCloudProvider(tc.provider) + if cfg.IsLocal != tc.wantLocal { + t.Errorf("IsLocal = %v, want %v", cfg.IsLocal, tc.wantLocal) + } + if cfg.CloudProvider != tc.wantCloud { + t.Errorf("CloudProvider = %q, want %q", cfg.CloudProvider, tc.wantCloud) + } + }) + } +} + +func TestIntentClassifyRequest(t *testing.T) { + req := IntentClassifyRequest("fix typo in README.md", "gpt-4o-mini") + if req.Model != "gpt-4o-mini" { + t.Errorf("Model = %q, want gpt-4o-mini", req.Model) + } + if req.MaxTokens != 30 { + t.Errorf("MaxTokens = %d, want 30", req.MaxTokens) + } + if req.Temperature != 0.0 { + t.Errorf("Temperature = %f, want 0.0", req.Temperature) + } + if len(req.Messages) != 1 { + t.Errorf("Messages = %d, want 1", len(req.Messages)) + } + if req.Messages[0].Content != "fix typo in README.md" { + t.Errorf("Message content = %q", req.Messages[0].Content) + } + if req.System != ai.IntentClassifyPrompt() { + t.Errorf("System prompt mismatch") + } +} + +func TestSimpleMutationPrompt(t *testing.T) { + p := ai.SimpleMutationPrompt() + if p == "" { + t.Fatal("SimpleMutationPrompt() returned empty string") + } + if !contains(p, "SEARCH") { + t.Error("SimpleMutationPrompt() missing SEARCH") + } + if !contains(p, "REPLACE") { + t.Error("SimpleMutationPrompt() missing REPLACE") + } + if !contains(p, "<<<<<<<") { + t.Error("SimpleMutationPrompt() missing <<<<<<<") + } +} + +func TestIntentClassifyPrompt(t *testing.T) { + p := ai.IntentClassifyPrompt() + if p == "" { + t.Fatal("IntentClassifyPrompt() returned empty string") + } + if !contains(p, "MUTATE") { + t.Error("IntentClassifyPrompt() missing MUTATE") + } + if !contains(p, "DIAGNOSE") { + t.Error("IntentClassifyPrompt() missing DIAGNOSE") + } +} + +func TestComplexityTierString(t *testing.T) { + tests := []struct { + tier ComplexityTier + want string + }{ + {TierUnknown, "UNKNOWN"}, + {TierTrivialCreate, "TRIVIAL_CREATE"}, + {TierSimpleMutation, "SIMPLE_MUTATION"}, + {TierComplexBuild, "COMPLEX_BUILD"}, + {ComplexityTier(99), "UNKNOWN"}, + } + for _, tc := range tests { + if got := tc.tier.String(); got != tc.want { + t.Errorf("ComplexityTier(%d).String() = %q, want %q", tc.tier, got, tc.want) + } + } +} + +func TestIsTrivialCreateTarget(t *testing.T) { + tests := []struct { + name string + file string + want bool + }{ + {"empty", "", false}, + {"LICENSE upper", "LICENSE", true}, + {"license lower", "license", true}, + {"LICENCE variant", "LICENCE", true}, + {".gitignore", ".gitignore", true}, + {"gitignore bare", "gitignore", true}, + {".env", ".env", true}, + {"env bare", "env", true}, + {".env.example", ".env.example", true}, + {"main.go code file", "main.go", false}, + {"README.md", "README.md", false}, + {"Dockerfile", "Dockerfile", false}, + {"Makefile", "Makefile", false}, + {"path/to/LICENSE", "path/to/LICENSE", true}, + {"path/.gitignore", "path/.gitignore", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := IsTrivialCreateTarget(tc.file) + if got != tc.want { + t.Errorf("IsTrivialCreateTarget(%q) = %v, want %v", tc.file, got, tc.want) + } + }) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && containsStr(s, substr) +} + +func containsStr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/modes/plan/engine.go b/internal/modes/plan/engine.go index c59b3aa..f6145a0 100644 --- a/internal/modes/plan/engine.go +++ b/internal/modes/plan/engine.go @@ -96,6 +96,23 @@ func (e *Engine) processFromLedger(ctx context.Context, ledgerContent string, pr return nil, fmt.Errorf("plan engine: provider not set") } + // ── DIRECT MUTATION FAST-TRACK ────────────────────────── + // When the prompt is a simple file replacement (refactor LICENSE + // from MIT to APACHE, change X to Y in @file, etc.), bypass + // /investigate mode entirely. Do NOT run test suites (go test). + // Route directly to BUILD / MUTATION pipeline with a + // deterministic, hardcoded task — zero LLM synthesis needed. + // + // This implements the "Direct Mutation Fast-Track Rule": + // 1. Detect direct mutation intent in the prompt/problem text. + // 2. Route directly to a deterministic FILE_MUTATE task. + // 3. Skip investigation, test execution, and JSON synthesis. + if !fastTrack { + if target := detectDirectMutation(problem, ledgerContent); target != nil { + return []Task{*target}, nil + } + } + // ── CANONICAL IMPORT MISMATCH (lx coordinate handshake) ────────────── // When the ledger contains a canonical import path mismatch error // ("module declares its path as: X but was required as: Y"), use the lx @@ -272,6 +289,18 @@ func (e *Engine) processFromLedger(ctx context.Context, ledgerContent string, pr MaxTokens: 500, } } else { + // ── DIRECT MUTATION ZERO-PROSE PROMPT ───────────── + // When the problem/ledger indicates a direct file mutation + // (refactor, convert, replace, etc.), use the zero-prose + // system prompt that skips all Senior Architect analysis + // sections (no CONTEXT & ROLE, no FORENSIC HANDOFF VECTOR). + // Forces the LLM to output only the direct task item. + isDirectMut := detectDirectMutation(problem, ledgerContent) != nil + systemPrompt := prompt.PlanSystemPrompt() + "\n\n" + SchemaJSONInstruction() + if isDirectMut { + systemPrompt = prompt.PlanDirectMutationSystemPrompt() + } + // Extract the investigation conclusion so it can be injected as a // high-priority override signal. The conclusion carries the resolved // diagnosis (e.g. corrected dependency paths) that must take precedence @@ -282,11 +311,11 @@ func (e *Engine) processFromLedger(ctx context.Context, ledgerContent string, pr Messages: []ai.Message{ { Role: "system", - Content: prompt.PlanSystemPrompt() + "\n\n" + SchemaJSONInstruction(), + Content: systemPrompt, }, { Role: "user", - Content: prompt.BuildPlanJSONPrompt(problem, ledgerContent, conclusion), + Content: prompt.BuildPlanJSONPrompt(problem, ledgerContent, conclusion, isDirectMut), }, }, Stream: false, @@ -1000,27 +1029,35 @@ func truncateForLog(s string) string { } // ProcessPlan generates an execution plan by dispatching to the AI provider -// with strict JSON output enforcement. +// with strict JSON output enforcement. When the objective indicates a +// direct file mutation, bypasses the Senior Architect prompt and uses +// the zero-prose direct mutation prompt instead. func (e *Engine) ProcessPlan(ctx context.Context, modelName string, objective string, contextStr string) error { if e == nil || e.provider == nil { return nil } + isDirectMut := detectDirectMutation(objective, "") != nil + req := ai.Request{ Model: modelName, Messages: []ai.Message{ { Role: "system", - Content: prompt.PlanSystemPrompt(), + Content: prompt.PlanSystemPrompt() + "\n\n" + SchemaJSONInstruction(), }, { Role: "user", - Content: prompt.BuildPlanPrompt(objective, contextStr), + Content: prompt.BuildPlanPrompt(objective, contextStr, isDirectMut), }, }, Stream: false, } + if isDirectMut { + req.Messages[0].Content = prompt.PlanDirectMutationSystemPrompt() + } + resp, err := e.provider(ctx, req) if err != nil { return err @@ -1053,6 +1090,116 @@ func (e *Engine) TickTask(stepNum int) error { return e.store.TickTaskHoanThanh(stepNum) } +// directMutationVerbs are verbs/phrases that signal an intent to +// perform a simple file replacement or format conversion rather than +// a diagnosis or investigation. Order matters: longer phrases first. +var directMutationVerbs = []string{ + "refactor", "change", "convert", "replace", "update", "modify", + "reformat", "transform", "switch", "migrate", "change to", +} + +// directMutationFilePattern matches prompts that reference a specific +// file and want to change its content or format (e.g. "refactor MIT LICENSE to APACHE"). +var directMutationFilePattern = regexp.MustCompile(`(?i)(license|readme|dockerfile|makefile|\.env|\.gitignore)\b`) + +// detectDirectMutation inspects the problem description and ledger +// content to determine whether this is a simple file mutation that +// should bypass the investigation/LLM synthesis pipeline entirely. +// Returns a deterministic hardcoded FILE_MUTATE task when the input +// qualifies, or nil when normal processing should continue. +func detectDirectMutation(problem string, ledgerContent string) *Task { + combined := strings.ToLower(strings.TrimSpace(problem) + " " + strings.TrimSpace(ledgerContent)) + if combined == "" { + return nil + } + + hasVerb := false + for _, v := range directMutationVerbs { + if strings.Contains(combined, v) { + hasVerb = true + break + } + } + if !hasVerb { + return nil + } + + if !directMutationFilePattern.MatchString(combined) { + return nil + } + + targetFile := extractMutationTarget(combined) + if targetFile == "" { + targetFile = "LICENSE" + } + + sourceFormat, targetFormat := extractFormatChange(combined) + + solution := fmt.Sprintf("File %s mutated successfully.", targetFile) + if sourceFormat != "" && targetFormat != "" { + solution = fmt.Sprintf("Converted %s from %s to %s in %s.", targetFile, sourceFormat, targetFormat, targetFile) + } + + return &Task{ + StepNum: 1, + IsDone: false, + Status: "idle", + Type: "FILE_MUTATE", + Target: targetFile, + Description: fmt.Sprintf("Refactor %s: %s%s", targetFile, sourceFormat, targetFormat), + Rationale: "Direct file mutation detected — bypass investigation and LLM synthesis.", + Solution: solution, + IsHardcoded: true, + } +} + +// extractMutationTarget finds the target filename from a mutation prompt string. +func extractMutationTarget(lower string) string { + if strings.Contains(lower, "license") { + return "LICENSE" + } + if strings.Contains(lower, "readme") { + return "README.md" + } + if strings.Contains(lower, "dockerfile") { + return "Dockerfile" + } + if strings.Contains(lower, "makefile") { + return "Makefile" + } + if strings.Contains(lower, ".env") { + return ".env" + } + if strings.Contains(lower, ".gitignore") { + return ".gitignore" + } + return "LICENSE" +} + +// extractFormatChange tries to identify the source and target formats +// from a mutation prompt (e.g. "MIT" → "APACHE_2.0"). +func extractFormatChange(lower string) (sourceFormat, targetFormat string) { + formatPatterns := []struct { + source string + target string + }{ + {"mit", "apache_2.0"}, + {"apache", "mit"}, + {"gpl", "mit"}, + {"mit", "gpl"}, + {"bsd", "apache_2.0"}, + {"apache", "bsd"}, + } + for _, fp := range formatPatterns { + if strings.Contains(lower, fp.source) { + sourceFormat = strings.ToUpper(fp.source) + targetFormat = strings.ToUpper(fp.target) + return sourceFormat, targetFormat + } + } + return "", "" +} + // PlanSchemaError indicates a plan output schema violation. type PlanSchemaError struct { Message string diff --git a/internal/modes/plan/plan_test.go b/internal/modes/plan/plan_test.go index 8610550..4141e19 100644 --- a/internal/modes/plan/plan_test.go +++ b/internal/modes/plan/plan_test.go @@ -730,3 +730,63 @@ func TestDependencyFromConclusion_NoBlocker(t *testing.T) { t.Fatalf("expected empty, got %q", dep) } } + +func TestDetectDirectMutation_RefactorLicenseMITtoApache(t *testing.T) { + task := detectDirectMutation("refactor MIT LICENSE to APACHE 2.0 LICENSE @LICENSE", "") + if task == nil { + t.Fatal("detectDirectMutation returned nil for refactor MIT LICENSE to APACHE") + } + if task.Type != "FILE_MUTATE" { + t.Errorf("Type = %q, want FILE_MUTATE", task.Type) + } + if task.Target != "LICENSE" { + t.Errorf("Target = %q, want LICENSE", task.Target) + } + if !task.IsHardcoded { + t.Error("IsHardcoded = false, want true") + } + if task.Rationale == "" { + t.Error("Rationale should not be empty") + } +} + +func TestDetectDirectMutation_ChangeLicense(t *testing.T) { + task := detectDirectMutation("change license from MIT to Apache 2.0 in LICENSE file", "") + if task == nil { + t.Fatal("detectDirectMutation returned nil for change license prompt") + } + if task.Target != "LICENSE" { + t.Errorf("Target = %q, want LICENSE", task.Target) + } + if task.Rationale == "" { + t.Error("Rationale should not be empty") + } +} + +func TestDetectDirectMutation_NoVerb(t *testing.T) { + task := detectDirectMutation("the LICENSE file is MIT format", "") + if task != nil { + t.Error("detectDirectMutation should return nil for non-mutation prompt") + } +} + +func TestDetectDirectMutation_NoFile(t *testing.T) { + // No file reference (license, readme, etc.) — should not fast-track. + task := detectDirectMutation("refactor MIT to Apache", "") + if task != nil { + t.Error("detectDirectMutation should return nil when no file target is detected") + } +} + +func TestDetectDirectMutation_ConvertGPLtoMIT(t *testing.T) { + task := detectDirectMutation("convert GPL license to MIT license", "") + if task == nil { + t.Fatal("detectDirectMutation returned nil for convert GPL to MIT") + } + if task.Target != "LICENSE" { + t.Errorf("Target = %q, want LICENSE", task.Target) + } + if task.Rationale == "" { + t.Error("Rationale should not be empty") + } +} diff --git a/internal/modes/plan/synthesis.go b/internal/modes/plan/synthesis.go new file mode 100644 index 0000000..f6a629e --- /dev/null +++ b/internal/modes/plan/synthesis.go @@ -0,0 +1,87 @@ +package plan + +import ( + "encoding/json" + "fmt" + "strings" +) + +type FlatPlanSpec struct { + Target string `json:"target"` + Action string `json:"action"` + TemplateKey string `json:"template_key,omitempty"` +} + +func ParseFlatPlanSpec(content string) (*FlatPlanSpec, error) { + content = strings.TrimSpace(content) + if content == "" { + return nil, nil + } + + content = stripJSONCodeFence(content) + + var spec FlatPlanSpec + if err := json.Unmarshal([]byte(content), &spec); err != nil { + return nil, err + } + + if spec.Target == "" { + return nil, nil + } + + return &spec, nil +} + +func stripJSONCodeFence(content string) string { + content = strings.TrimSpace(content) + for strings.HasPrefix(content, "```") { + firstNewline := strings.Index(content, "\n") + if firstNewline != -1 { + content = content[firstNewline+1:] + } else { + break + } + content = strings.TrimSpace(content) + } + for strings.HasSuffix(content, "```") { + lastBackticks := strings.LastIndex(content, "```") + if lastBackticks != -1 { + content = strings.TrimSpace(content[:lastBackticks]) + } else { + break + } + } + content = strings.TrimSpace(content) + return content +} + +func FlatSpecToTasks(spec *FlatPlanSpec) []Task { + if spec == nil || spec.Target == "" { + return nil + } + + taskType := "FILE_MUTATE" + if spec.Action == "SHELL_EXEC" { + taskType = "SHELL_EXEC" + } + + description := fmt.Sprintf("%s %s", spec.Action, spec.Target) + solution := fmt.Sprintf("Completed %s on %s", spec.Action, spec.Target) + + if spec.TemplateKey != "" { + description = fmt.Sprintf("Apply %s template to %s", spec.TemplateKey, spec.Target) + solution = fmt.Sprintf("Applied %s template to %s", spec.TemplateKey, spec.Target) + } + + return []Task{{ + StepNum: 1, + IsDone: false, + Status: "idle", + Type: taskType, + Target: spec.Target, + Description: description, + Rationale: fmt.Sprintf("Flat plan spec: action=%s target=%s", spec.Action, spec.Target), + Solution: solution, + IsHardcoded: true, + }} +} diff --git a/internal/modes/plan/synthesis_test.go b/internal/modes/plan/synthesis_test.go new file mode 100644 index 0000000..3ad241f --- /dev/null +++ b/internal/modes/plan/synthesis_test.go @@ -0,0 +1,115 @@ +package plan + +import ( + "testing" +) + +func TestParseFlatPlanSpec_Basic(t *testing.T) { + input := `{"target": "LICENSE", "action": "REPLACE", "template_key": "apache-2.0"}` + spec, err := ParseFlatPlanSpec(input) + if err != nil { + t.Fatalf("ParseFlatPlanSpec error: %v", err) + } + if spec == nil { + t.Fatal("ParseFlatPlanSpec returned nil") + } + if spec.Target != "LICENSE" { + t.Errorf("Target = %q, want LICENSE", spec.Target) + } + if spec.Action != "REPLACE" { + t.Errorf("Action = %q, want REPLACE", spec.Action) + } + if spec.TemplateKey != "apache-2.0" { + t.Errorf("TemplateKey = %q, want apache-2.0", spec.TemplateKey) + } +} + +func TestParseFlatPlanSpec_CodeFence(t *testing.T) { + input := "```json\n{\"target\": \"LICENSE\", \"action\": \"REPLACE\", \"template_key\": \"apache-2.0\"}\n```" + spec, err := ParseFlatPlanSpec(input) + if err != nil { + t.Fatalf("ParseFlatPlanSpec error: %v", err) + } + if spec == nil { + t.Fatal("ParseFlatPlanSpec returned nil for code fence input") + } + if spec.Target != "LICENSE" { + t.Errorf("Target = %q, want LICENSE", spec.Target) + } +} + +func TestParseFlatPlanSpec_Empty(t *testing.T) { + spec, err := ParseFlatPlanSpec("") + if err != nil { + t.Fatalf("ParseFlatPlanSpec error: %v", err) + } + if spec != nil { + t.Error("ParseFlatPlanSpec(\"\") should return nil") + } +} + +func TestParseFlatPlanSpec_MissingTarget(t *testing.T) { + input := `{"action": "REPLACE", "template_key": "apache-2.0"}` + spec, err := ParseFlatPlanSpec(input) + if err != nil { + t.Fatalf("ParseFlatPlanSpec error: %v", err) + } + if spec != nil { + t.Error("ParseFlatPlanSpec with missing target should return nil") + } +} + +func TestFlatSpecToTasks_Basic(t *testing.T) { + spec := &FlatPlanSpec{ + Target: "LICENSE", + Action: "REPLACE", + TemplateKey: "apache-2.0", + } + tasks := FlatSpecToTasks(spec) + if len(tasks) != 1 { + t.Fatalf("FlatSpecToTasks returned %d tasks, want 1", len(tasks)) + } + if tasks[0].Type != "FILE_MUTATE" { + t.Errorf("Type = %q, want FILE_MUTATE", tasks[0].Type) + } + if tasks[0].Target != "LICENSE" { + t.Errorf("Target = %q, want LICENSE", tasks[0].Target) + } + if !tasks[0].IsHardcoded { + t.Error("IsHardcoded = false, want true") + } +} + +func TestFlatSpecToTasks_ShellExec(t *testing.T) { + spec := &FlatPlanSpec{ + Target: "go mod tidy", + Action: "SHELL_EXEC", + } + tasks := FlatSpecToTasks(spec) + if len(tasks) != 1 { + t.Fatalf("FlatSpecToTasks returned %d tasks, want 1", len(tasks)) + } + if tasks[0].Type != "SHELL_EXEC" { + t.Errorf("Type = %q, want SHELL_EXEC", tasks[0].Type) + } + if tasks[0].Target != "go mod tidy" { + t.Errorf("Target = %q, want go mod tidy", tasks[0].Target) + } +} + +func TestFlatSpecToTasks_Nil(t *testing.T) { + tasks := FlatSpecToTasks(nil) + if tasks != nil { + t.Errorf("FlatSpecToTasks(nil) should return nil, got %v", tasks) + } +} + +func TestFlatSpecToTasks_EmptyTarget(t *testing.T) { + spec := &FlatPlanSpec{ + Action: "REPLACE", + } + tasks := FlatSpecToTasks(spec) + if tasks != nil { + t.Errorf("FlatSpecToTasks with empty target should return nil, got %v", tasks) + } +} diff --git a/internal/prompt/ask.go b/internal/prompt/ask.go index b36c466..8d7b94f 100644 --- a/internal/prompt/ask.go +++ b/internal/prompt/ask.go @@ -3,38 +3,35 @@ package prompt import "fmt" // AskPromptHandoffContract returns the IZEN INTELLIGENT PROMPT HANDOFF PACK -// template. It instructs the LLM to act as a Strict Senior Architect that -// evaluates, prunes, and refines the user's raw architectural idea into -// five structured sections — no session history aggregation, no JSON wrapping. +// template. Instructs the LLM to evaluate, prune, and restructure a raw +// architectural idea into exactly 5 structured sections. func AskPromptHandoffContract() string { - return `========================================= -🚀 IZEN INTELLIGENT PROMPT HANDOFF PACK -========================================= + return `IZEN INTELLIGENT PROMPT HANDOFF PACK -You are acting as a Strict Senior DevOps / Systems Architect. Your task is to evaluate the user's raw architectural idea (provided below), prune ambiguities, eliminate conversational noise, and restructure it into exactly 5 sections using standard markdown dividers (##, *, [ ]). Act with the rigor of a senior engineer reviewing a junior teammate's design draft — be precise, critical, and constructive. +You are a Strict Senior DevOps / Systems Architect. Evaluate the raw architectural idea below, prune ambiguities, eliminate conversational noise, and restructure it into exactly 5 sections. Act with the rigor of a senior engineer reviewing a junior's design draft — precise, critical, constructive. -Output EXACTLY this structure with no preamble, no explanation, and no trailing commentary: +Output EXACTLY this structure. No preamble, no explanation, no trailing commentary: ## 1. CONTEXT & ROLE - Target Role: [e.g., Senior DevOps / Database Architect / Go Core Expert] -- System Context: [Brief, refined summary of the project state and target scope from the user's raw text] +- System Context: [Brief, refined summary of project state and target scope] ## 2. PROBLEM STATEMENT -- Core Idea: [Precise technical description of the core issue or feature — stripped of ambiguity] -- Symptoms / Motivation: [What the user originally described, rephrased as concrete technical signals] +- Core Idea: [Precise technical description — stripped of ambiguity] +- Symptoms / Motivation: [User's original description rephrased as concrete technical signals] ## 3. EXPECTATION -- [ ] Concrete Objective 1 (Physical output deliverables, target files to modify) -- [ ] Concrete Objective 2 (Acceptance criteria, performance constraints, or test definitions) +- [ ] Concrete Objective 1 (physical output deliverables, target files to modify) +- [ ] Concrete Objective 2 (acceptance criteria, performance constraints, or test definitions) ## 4. SMART ANALYSIS & TRADEOFFS -- Proposed Solution: [The architectural approach chosen for the fix/feature] +- Proposed Solution: [Architectural approach chosen] - Pros: [Benefits of this implementation] -- Cons & Tradeoffs: [The cost paid, e.g., context token inflation, backward compatibility risks, performance overhead] +- Cons & Tradeoffs: [Cost: context token inflation, backward compatibility risks, performance overhead] ## 5. FORENSIC HANDOFF VECTOR -- Diagnostic Targets: [List of specific source files, functions, or directories to deep-dive inspect] -- Command Target: [Target test or run commands required to fetch real-world runtime logs] +- Diagnostic Targets: [Specific source files, functions, or directories to inspect] +- Command Target: [Target test or run commands to fetch real-world runtime logs] Now refine the following raw user input:` } @@ -49,13 +46,9 @@ func AskContract() string { fence := "```" return fmt.Sprintf(`MODE: /ask — increase understanding. -PURPOSE -- Help the engineer understand. Explain, inspect, compare, answer, and clarify. - PERMISSIONS -- Inspect the provided code context and explain how it works. +- Explain, inspect, compare, answer, and clarify code and concepts. - Answer general software engineering, architecture, syntax, and language questions directly. -- Answer questions within the localized code context when referenced. - Compare alternatives and recommend approaches. FORBIDDEN @@ -63,16 +56,17 @@ FORBIDDEN - Do NOT perform any execution or mutation. CONTEXT SCOPE -- If the user asks a general technical or conceptual question (e.g. "what is Golang", "explain closures", "what is Rust"), answer it immediately, directly, and comprehensively without requiring or begging for local project context. -- If the user gives an explicit @file reference, restrict your local code reasoning to those files only. -- If no @file reference is given but localized context exists, use it as the anchor for reasoning ONLY if the query is project-related. +- General technical question (e.g. "what is Golang", "explain closures") → answer immediately and comprehensively; no local project context required. +- Explicit @file reference → restrict local code reasoning to those files only. +- No @file reference but localized context exists → use it as anchor ONLY if the query is project-related. - Never propose file edits or execution plans unless explicitly asked. -OUTPUT — engineering explanation +OUTPUT - Use clean, standard Markdown. -- Lists use only the hyphen format: "- **Key**: Description". Never use custom bullet characters. -- Emphasis uses only standard double asterisks: "**bold text**". Never leak raw HTML or custom symbols. -- Wrap all code or terminal output in a language-specific fence (e.g. %sgo, %sdiff). Only use %splaintext for raw, unformatted logs. -- Keep prose and code strictly separated — no conversational text or meta-commentary inside code fences. -- When answering general Q&A, conclude with a helpful summary. When discussing project-specific code, you may end with a target-oriented question to scope the next step.`, fence, fence, fence) +- Lists: hyphen format only — "- **Key**: Description". No custom bullet characters. +- Emphasis: standard double asterisks only — "**bold**". No raw HTML or custom symbols. +- Wrap all code/terminal output in language-specific fences (e.g. %sgo, %sdiff). Use %splaintext only for raw, unformatted logs. +- Keep prose and code strictly separated — no conversational text inside code fences. +- General Q&A: conclude with a helpful summary. Project-specific: end with a targeted follow-up question to scope the next step.`, + fence, fence, fence) } diff --git a/internal/prompt/build.go b/internal/prompt/build.go index 5ddd2df..c083fef 100644 --- a/internal/prompt/build.go +++ b/internal/prompt/build.go @@ -1,23 +1,22 @@ package prompt // BuildContract returns the operational contract for build mode. +// +// Purpose: execute an approved implementation with zero prose. +// Output: SEARCH/REPLACE blocks for existing files; FILE_CREATE blocks for new files. func BuildContract() string { code := "```" - return `MODE: /build — execute an approved implementation. - -PURPOSE -- Build performs execution only. No architectural reasoning, no explanations. No commentary. Only output. + return `MODE: /build — execute approved implementation. No reasoning, no explanations, no commentary. FORBIDDEN -- ZERO conversational prose, explanations, introductions, or summaries. -- ZERO full-file repeats outside SEARCH/REPLACE blocks. -- ZERO raw code snippets without SEARCH/REPLACE markers for existing files. -- The first output token MUST be a SEARCH/REPLACE block or a FILE_CREATE block. No exceptions. +- Any conversational prose, introductions, summaries, or explanations. +- Full-file repeats outside SEARCH/REPLACE blocks. +- Raw code snippets without SEARCH/REPLACE markers for existing files. +- The first output token MUST be a SEARCH/REPLACE or FILE_CREATE block. No exceptions. -ALLOWED OUTPUT FORMATS +OUTPUT FORMATS -**METHOD C — SEARCH/REPLACE BLOCK (REQUIRED for existing files)** -Use EXACTLY this format. SEARCH block must contain exactly the lines to match. REPLACE block contains the new lines. +METHOD C — SEARCH/REPLACE (required for existing files) ` + code + `go:path/to/file.go <<<<<<< SEARCH "log" @@ -29,8 +28,7 @@ Use EXACTLY this format. SEARCH block must contain exactly the lines to match. R >>>>>>> ` + code + ` -**METHOD B — FILE_CREATE BLOCK (REQUIRED for new files)** -Use EXACTLY this format. The file path follows FILE_CREATE: on the first line. Content is the complete file. +METHOD B — FILE_CREATE (required for new files) ` + code + ` <<<<<<< FILE_CREATE: path/to/newfile.go package main @@ -39,12 +37,11 @@ func main() {} ` + code + ` RULES -- Existing files: Use METHOD C (SEARCH/REPLACE). -- New files: MUST use METHOD B (FILE_CREATE) — never SEARCH/REPLACE for new files. +- Existing files → METHOD C (SEARCH/REPLACE). New files → METHOD B (FILE_CREATE). Never mix. - SEARCH blocks are whitespace-sensitive. Copy lines EXACTLY from the original file. -- SEARCH block must uniquely identify the region (at least 2-3 lines). -- NEVER output prose, explanations, or markdown outside the blocks. -- ON ERROR: If SEARCH fails, retry with whitespace-trimmed matching before switching to METHOD B. -- SHELL_EXEC tasks MUST contain only executable commands, never code diffs. -- The output MUST end immediately after the last REPLACE/SEARCH/FILE_CREATE block. No trailing text.` +- SEARCH block must uniquely identify the region (at least 2–3 lines of context). +- No prose, explanations, or markdown outside the blocks. +- ON ERROR: retry with whitespace-trimmed matching before switching to METHOD B. +- SHELL_EXEC tasks contain only executable commands — never code diffs. +- Output ends immediately after the last block. No trailing text.` } diff --git a/internal/prompt/common.go b/internal/prompt/common.go index e5c4774..d7b9bc9 100644 --- a/internal/prompt/common.go +++ b/internal/prompt/common.go @@ -2,39 +2,31 @@ package prompt // CommonContract returns the constitutional prompt shared by every mode. // -// It contains only principles that are universally true for IZEN. It must -// never contain mode-specific behavior, output formatting, language-specific -// compiler rules, or execution workflow. Think of it as the Constitution. +// Identity injection ("You are IZEN. The engineer is X.") is handled exclusively +// by Compose via RuntimeFacts — never duplicate it here. This contract contains +// only universal engineering principles, written once, reused everywhere. func CommonContract() string { - return `You are IZEN — a deterministic engineering intelligence, not a general-purpose assistant. + return `IDENTITY: You are IZEN — a deterministic engineering intelligence. Never claim to be anything else. -IDENTITY -- You are IZEN, a precision engineering runtime. Never claim to be anything else. - -ENGINEERING PHILOSOPHY -- System behavior is enforced in code. Prompts only seed intelligence; the runtime enforces permissions, retrieval, graph lookup, semantic search, shell execution, checkpoints, and verification. -- Modes define operational boundaries. Each mode owns exactly one responsibility and must not overstep it. - -HUMAN-CENTERED PRINCIPLES -- You serve the engineer. Every output should turn vague intent into a concrete, actionable objective. -- The human retains final control. Never silently take actions that the current mode forbids. +PRINCIPLES +- Serve the engineer. Turn vague intent into concrete, actionable output. +- The human retains final control. Never silently take actions the current mode forbids. +- Modes define operational boundaries. Each mode owns exactly one responsibility. +- System behavior is enforced by the runtime (permissions, retrieval, graph lookup, shell exec, checkpoints). Prompts only seed intelligence. TRUTHFULNESS -- Do not hallucinate or invent API specifications, function signatures, library behavior, or file contents. If uncertain, explicitly quantify your uncertainty. - -DETERMINISTIC BEHAVIOR -- Be decisive. When evidence strongly supports a conclusion, state it. Do not pad output with hedging or meta-commentary. - -EVIDENCE-FIRST REASONING -- Ground every claim in the provided codebase context. Inspect before asserting. Reason from facts, not assumptions. +- Never hallucinate API specs, function signatures, library behavior, or file contents. +- When uncertain, explicitly quantify uncertainty. Do not guess. -CAPABILITY AWARENESS -- Prompts assume the runtime has already enforced permissions. Operate strictly within the boundaries of the active mode. +OUTPUT DISCIPLINE +- Be decisive. State conclusions when evidence strongly supports them. +- No hedging, padding, or meta-commentary in responses. +- Ground every claim in provided codebase context. Inspect before asserting. -CLARIFICATION PRINCIPLES -- If the technical context is ambiguous, surface the exact missing requirements and ask precise, targeted questions. Do not guess or hallucinate a solution. +CLARIFICATION +- Surface exact missing requirements with precise, targeted questions. Never guess a solution from ambiguous context. -GLOBAL INVARIANTS -- Respond strictly in the SAME language the engineer used in their most recent message. If they write in English, answer in English; if in Chinese, answer in Chinese. Never switch languages, mix scripts, or translate unless the engineer explicitly asks. -- The engineer's identity is supplied as a runtime fact and persists across all turns; honor it.` +INVARIANTS +- Respond in the SAME language the engineer used in their most recent message. Never switch, mix, or translate unless explicitly asked. +- The engineer's identity is a runtime fact that persists across all turns; honor it.` } diff --git a/internal/prompt/investigate.go b/internal/prompt/investigate.go index 77a79bc..874d15f 100644 --- a/internal/prompt/investigate.go +++ b/internal/prompt/investigate.go @@ -2,41 +2,37 @@ package prompt // InvestigateContract returns the operational contract for investigate mode. // -// Phase 1 (Heavyweight Data Processor): /investigate is the PRIMARY data -// processor. It absorbs all raw logs, stack traces, and test states, then -// outputs a compact, strictly validated, and token-optimized Forensic Ledger -// JSON. /plan receives ONLY the absolute deterministic facts needed for code -// modification — no background noise, no verbose dumps. +// Phase 1 (Heavyweight Data Processor): absorbs all raw logs, stack traces, +// and test states; outputs a compact, strictly validated, token-optimized +// Forensic Ledger JSON for direct consumption by /plan. No background noise, +// no verbose dumps — only deterministic facts. func InvestigateContract() string { - return `MODE: /investigate — Heavyweight Data Processor & Forensic Ledger Compiler + return `MODE: /investigate — Forensic Ledger Compiler -ROLE -- You are a forensic data compressor, not a conversational analyst. -- Your single output is a strictly validated, token-optimized Forensic Ledger. -- Every finding must be a deterministic fact — no speculation, no padding. +ROLE: forensic data compressor. Single output: a validated, token-optimized Forensic Ledger JSON. +Every finding must be a deterministic fact — no speculation, no padding. PROTOCOL 1. Absorb ALL raw input: logs, stack traces, test output, compiler errors. 2. Distill to EXACTLY: root_cause, affected files, error coordinates, conclusion. -3. Strip ALL noise: ANSI codes, progress bars, download logs, environment setup. +3. Strip ALL noise: ANSI codes, progress bars, download logs, environment setup messages. 4. Output ONLY raw JSON — zero conversational text, zero markdown, zero chit-chat. -COMPULSORY FIELDS (every investigation MUST populate these): - - "root_cause": one-line exact description of the fault (e.g. "missing module github.com/moby/moby/client in go.mod") - - "targets": array of {file, line, node, kind} — the EXACT code coordinates - - "conclusion": the resolved diagnosis that /plan will map directly to tasks - - "resolved": true if root cause is confirmed, false if inconclusive +COMPULSORY FIELDS (every investigation must populate these) +- "root_cause": one-line exact description (e.g. "missing module github.com/moby/moby/client in go.mod") +- "targets": array of {file, line, node, kind} — exact code coordinates +- "conclusion": resolved diagnosis that /plan maps directly to tasks +- "resolved": true if root cause is confirmed, false if inconclusive -TOKEN BUDGET RULES +TOKEN BUDGET - Total output MUST stay under 2000 characters. - Every line must carry unique diagnostic signal. - Drop all stack frames beyond the first 3 relevant frames. - Condense repeated compiler errors into one canonical error line. - Never repeat the same file:line coordinate more than once. -STRICT OUTPUT REQUIREMENT -- OUTPUT MUST BE RAW JSON ONLY — no markdown fences, no // comments, no /* */ blocks. -- The first non-whitespace character MUST be '{'. -- The last non-whitespace character MUST be '}'. +OUTPUT REQUIREMENT +- RAW JSON ONLY — no markdown fences, no // comments, no /* */ blocks. +- First non-whitespace character MUST be '{'. Last MUST be '}'. - VIOLATING THESE RULES WILL CRASH THE DOWNSTREAM /plan PARSER.` } diff --git a/internal/prompt/plan.go b/internal/prompt/plan.go index b13053a..0b92b4a 100644 --- a/internal/prompt/plan.go +++ b/internal/prompt/plan.go @@ -5,34 +5,32 @@ import ( "runtime" ) -// EnvironmentContext returns a compact, authoritative statement of the host -// runtime environment (using the actual runtime.GOOS/GOARCH). Injecting this -// into the /plan (and /build) prompts anchors the model to the ACTUAL -// operating system so it does not hallucinate platform-specific commands for -// the wrong OS (e.g. `apt-get`/`sudo` on a macOS host where `brew`/`go install` -// are correct). +// EnvironmentContext returns a compact, authoritative host runtime statement +// using the actual runtime.GOOS/GOARCH. Injecting this anchors the model to +// the ACTUAL operating system so it never hallucinate OS-specific commands +// (e.g. `apt-get` on a macOS host where `brew`/`go install` are correct). func EnvironmentContext() string { return EnvironmentContextForOS(runtime.GOOS) } -// EnvironmentContextForOS is the OS-parameterised variant used by the central -// prompt composer (registry.Compose), which receives the host OS from the -// runtime and threads it into every mode's system prompt. +// EnvironmentContextForOS is the OS-parameterised variant used by Compose, +// which receives the host OS from the runtime and threads it into every mode. func EnvironmentContextForOS(os string) string { arch := runtime.GOARCH manager := osPackageManager(os) - return fmt.Sprintf("HOST ENVIRONMENT CONSTRAINT — you are executing on %s/%s. "+ - "Generate commands ONLY for this OS. Preferred package/tooling command for this OS: %s. "+ - "NEVER emit commands for another OS (e.g. do not use `apt-get`/`apt`/`yum`/`dnf` on %s).", - os, arch, manager, os) + return fmt.Sprintf( + "HOST ENVIRONMENT: %s/%s. Generate commands ONLY for this OS. "+ + "Preferred tooling: %s. "+ + "NEVER emit commands for another OS (e.g. no `apt-get`/`apt`/`yum`/`dnf` on %s).", + os, arch, manager, os, + ) } -// osPackageManager maps a host OS to its correct package/dependency tooling, -// so the plan engine proposes the right command for the actual environment. +// osPackageManager maps a host OS to its correct package/dependency tooling. func osPackageManager(os string) string { switch os { case "darwin": - return "Homebrew (`brew`) — and Go modules via `go get`/`go mod tidy`" + return "Go modules via `go get`/`go mod tidy` / platform binary tooling" case "linux": return "the distro package manager (`apt`/`apt-get`, `dnf`, or `yum`) or `go install`" case "windows": @@ -43,80 +41,108 @@ func osPackageManager(os string) string { } // PlanContract defines the behavioral contract for /plan mode. -// Phase 2 (Lightweight Execution Mapper): /plan is a deterministic transformer. -// It does NOT re-analyze root cause. It reads the compact Forensic Ledger JSON -// from /investigate and maps it directly to structural atomic_tasks and the -// architectural_strategy. No conversational filler, no re-investigation. +// Phase 2 (Lightweight Execution Mapper): reads the compact Forensic Ledger +// from /investigate and maps it directly to structural atomic_tasks. No +// re-analysis, no conversational filler. func PlanContract() string { return `MODE: /plan — Structural Architecture Synthesis -ROLE -- You are a Senior Principal Structural Architect. +ROLE: Senior Principal Structural Architect. - Read the pre-compiled Forensic Ledger from /investigate. -- Synthesize a structured architectural plan with Root Core Factor, Impact Domain, Risk Evaluation, and Verification Vector. -- Each task MUST include: track classification, rationale (why), and expected solution (end state). +- Synthesize a structured plan: Root Core Factor, Impact Domain, Risk Evaluation, Verification Vector. +- Every task MUST include: track classification, rationale (why), expected solution (end state). - Do NOT re-analyze, re-investigate, or question the ledger. PROTOCOL -1. Read the Forensic Ledger below (compact JSON from /investigate). -2. Identify the Root Core Factor — one sentence describing the fundamental root cause. +1. Read the Forensic Ledger (compact JSON from /investigate). +2. Identify the Root Core Factor — one sentence stating the fundamental root cause. 3. Map root_cause → Task 1 (always the dependency/code fix). 4. Map targets → FILE_MUTATE tasks at exact {file, line} coordinates. 5. End with a verification task when applicable. -6. For EVERY task, provide: - - rationale: why this task is necessary (architectural/technical reason) - - solution: what the expected end state looks like after this task completes +6. For EVERY task provide: rationale (why this task is necessary) and solution (expected end state). 7. Output ONLY the JSON schema — zero explanation, zero commentary. - GO DEPENDENCY FACTORY TEMPLATE (STRICT) +GO DEPENDENCY RULE (STRICT) For missing Go package/module errors ("no required module provides package"): - CRITICAL: Extract the EXACT package path from the ledger conclusion (e.g., go get github.com/docker/docker/client). NEVER output literal angle brackets like . - PERMITTED: SHELL_EXEC with the actual package path from the ledger. - FORBIDDEN in command string: - - File names: go.mod, go.sum - - Relative paths: any path/to/file.go or ./path/ patterns - - Generalized text: prose, descriptions, or natural language - - brew, docker, apt, or any OS-level command - - Angle-bracket placeholders like or - The command MUST be a single, runnable shell invocation — not a file path. - -SINGLE-TASK MANDATE (7B TRUNCATION PREVENTION) -If the root_cause is a missing Go package (e.g. "no required module provides package"), -emit EXACTLY ONE task: SHELL_EXEC with go get . -YOU MUST substitute the real package path — do NOT output literal angle-bracket text. -No FILE_MUTATE, no GIT_ACTION, no brew/docker/environment tasks. -Total JSON MUST stay under 300 tokens. - -ANTI-HALLUCINATION (LOCAL 7B MODELS) -- If the ledger says "missing module X", Task 1 IS "go get X". -- Do NOT add brew install go, brew install docker, or any OS-level setup. +- Extract the EXACT package path from the ledger conclusion. +- Emit EXACTLY ONE SHELL_EXEC task: "go get ". +- Total JSON MUST stay under 300 tokens. +- FORBIDDEN in command string: file names (go.mod, go.sum), relative paths, prose, brew/docker/apt, angle-bracket placeholders. + +ANTI-HALLUCINATION +- Missing module X → Task 1 IS "go get X". No brew, no docker, no OS-level setup. - Never propose installing Go, Docker, or compilers — they already run. -- Keep tasks strictly at the code/dependency boundary. -- CRITICAL: the SHELL_EXEC target must be a real command (e.g. "go get github.com/foo/bar"), - NOT a file path or placeholder text. Commands like "relative/path/to/go.mod", - "./go.mod", or bare "go.mod" as the target are INVALID and will be rejected. +- SHELL_EXEC target MUST be a real runnable command (e.g. "go get github.com/foo/bar"), NOT a file path or placeholder. RULES - Tasks MUST be atomic, independently verifiable, ordered by dependency. - Missing dependency → Task 1 MUST be SHELL_EXEC with the exact install command. - FILE_MUTATE tasks MUST target the exact relative file path and line. -- End with a verification task when supported by the evidence. -- Tool constraint: use native Go tooling FIRST (` + "`go get`" + `, ` + "`go mod tidy`" + `, ` + "`go install`" + `). - Never default to system-level binaries (` + "`brew install`" + `, ` + "`docker`" + `).` + - "\n" +- Use native Go tooling first (` + "`go get`" + `, ` + "`go mod tidy`" + `, ` + "`go install`" + `). Never default to ` + "`brew install`" + ` or ` + "`docker`" + `.` } -// BuildPlanJSONPrompt builds the strict JSON prompt consumed by the TUI parser. -// Phase 2: Lightweight — reads the compact ledger, maps to tasks, no re-analysis. -func BuildPlanJSONPrompt(problem, ledgerContent, conclusion string) string { - conclusionBlock := "" - if conclusion != "" { - conclusionBlock = fmt.Sprintf(` +// planDirectives are the shared DIRECTIVES rules for BuildPlanJSONPrompt. +// Extracted to eliminate copy-paste between the isDirectMutation branches. +const planDirectives = `DIRECTIVES +- Map root_cause → Task 1: SHELL_EXEC for dep issues, FILE_MUTATE for code bugs. +- Go missing module → emit EXACTLY ONE task: {"task_id":1,"strategy":"SHELL_EXEC","target":"go get ","description":"install missing dependency","rationale":"","solution":""}. Use the ACTUAL package path — never literal angle brackets. +- FORBIDDEN as SHELL_EXEC target: file paths (go.mod, go.sum, ./relative/path), prose, or any non-runnable text. +- Do NOT add brew, docker, or environment setup tasks.` + +// conclusionBlock formats the authoritative ledger conclusion block, or +// returns an empty string when conclusion is empty. +func conclusionBlock(conclusion string) string { + if conclusion == "" { + return "" + } + return fmt.Sprintf(` CONCLUSION FROM LEDGER (authoritative — do not override) %s CRITICAL: Map this conclusion directly to a SHELL_EXEC task if dependency-related. -The SHELL_EXEC target MUST be a valid command with the ACTUAL package path from the ledger (e.g., "go get github.com/docker/docker/client"), not a file path or angle-bracket placeholder.`, conclusion) +The SHELL_EXEC target MUST be a valid command with the ACTUAL package path (e.g. "go get github.com/docker/docker/client"), not a file path or placeholder.`, + conclusion) +} + +// planJSONSchema is the canonical output schema for BuildPlanJSONPrompt. +const planJSONSchema = `{ + "context_anchor": {"source": "investigate-ledger", "target_packages": ["pkg"]}, + "architectural_strategy": "single sentence", + "strategic_overview": { + "root_core_factor": "fundamental root cause", + "impact_domain": "architectural layer affected", + "risk_evaluation": "Low | Medium | High | Critical", + "verification_vector": "how correctness will be verified" + }, + "atomic_tasks": [ + {"task_id": 1, "file": "relative/path", "strategy": "SHELL_EXEC", "description": "title", "rationale": "why this task is needed", "solution": "expected end state"} + ] +}` + +// BuildPlanJSONPrompt builds the strict JSON prompt consumed by the TUI parser. +// Phase 2: Lightweight — reads the compact ledger, maps to tasks, no re-analysis. +// When isDirectMutation is true, omits EnvironmentContext and constrains output +// to max_tokens: 150. +func BuildPlanJSONPrompt(problem, ledgerContent, conclusion string, isDirectMutation bool) string { + cb := conclusionBlock(conclusion) + + if isDirectMutation { + return fmt.Sprintf(`You are the IZEN Plan Mapper. Read the /investigate Forensic Ledger below and produce a JSON plan. + +INPUT: +PROBLEM: %s +FORENSIC LEDGER: +%s%s + +%s +- Total JSON under 150 tokens. + +OUTPUT — raw JSON only, no fences, no comments: +%s`, + problem, ledgerContent, cb, + planDirectives, + planJSONSchema, + ) } return fmt.Sprintf(`You are the IZEN Plan Mapper. Read the /investigate Forensic Ledger below and produce a JSON plan. @@ -128,40 +154,47 @@ PROBLEM: %s FORENSIC LEDGER: %s%s -DIRECTIVES: -- Map root_cause → Task 1 (SHELL_EXEC for dep issues, FILE_MUTATE for code bugs). -- If root_cause is a missing Go module, emit EXACTLY: {"task_id":1,"strategy":"SHELL_EXEC","target":"go get ","description":"install missing dependency","rationale":"why this is needed","solution":"expected end state"}. Substitute the real package path — NEVER output literal angle brackets. -- For EVERY task, provide rationale (why) and solution (expected end state). -- Include a root_core_factor sentence in strategic_overview describing the fundamental root cause. -- FORBIDDEN as SHELL_EXEC target: file paths (go.mod, go.sum, ./relative/path), generalized text, or prose. - The target field MUST be a runnable shell command starting with a binary name. -- Do NOT add brew, docker, or environment setup tasks. +%s +- For EVERY task provide rationale (why) and solution (expected end state). +- Include root_core_factor in strategic_overview describing the fundamental root cause. - Total JSON under 300 tokens. OUTPUT — raw JSON only, no fences, no comments: -{ - "context_anchor": {"source": "investigate-ledger", "target_packages": ["pkg"]}, - "architectural_strategy": "single sentence", - "strategic_overview": { - "root_core_factor": "The fundamental root cause driving this plan", - "impact_domain": "Architectural layer affected", - "risk_evaluation": "Low / Medium / High / Critical", - "verification_vector": "How correctness will be verified" - }, - "atomic_tasks": [ - {"task_id": 1, "file": "relative/path", "strategy": "SHELL_EXEC", "description": "title", "rationale": "why this task is needed", "solution": "expected end state"} - ] -}`, +%s`, EnvironmentContext(), - problem, - ledgerContent, - conclusionBlock, + problem, ledgerContent, cb, + planDirectives, + planJSONSchema, ) } +// planTaskBlocks is the shared output format for BuildPlanPrompt. +const planTaskBlocks = `OUTPUT — raw task blocks only, no prose: +- [ ] SHELL_EXEC: | +- [ ] FILE_MUTATE: | +- [ ] SHELL_EXEC: | verify + +RULES +- Missing Go dependency → output EXACTLY ONE SHELL_EXEC task with the ACTUAL package path (e.g. "go get github.com/docker/docker/client"). NOT a file path or placeholder. +- FORBIDDEN as SHELL_EXEC target: "go.mod", "go.sum", relative paths, or any non-command text. +- No brew, docker, or OS-level environment tasks. Stay at the code/dependency boundary.` + // BuildPlanPrompt builds the compact Markdown prompt for user-facing terminal output. // Phase 2: Stripped down — the LLM returns data, UI handles rendering. -func BuildPlanPrompt(objective, contextStr string) string { +// When isDirectMutation is true, constrains output to raw task blocks only. +func BuildPlanPrompt(objective, contextStr string, isDirectMutation bool) string { + if isDirectMutation { + return fmt.Sprintf(`%s + +USER OBJECTIVE +%s + +%s +- Total output under 150 tokens.`, + contextStr, objective, planTaskBlocks, + ) + } + return fmt.Sprintf(`%s %s @@ -169,19 +202,20 @@ func BuildPlanPrompt(objective, contextStr string) string { USER OBJECTIVE %s -OUTPUT — raw task blocks only, no prose: -- [ ] SHELL_EXEC: | -- [ ] FILE_MUTATE: | -- [ ] SHELL_EXEC: | verify - -RULES: -- If a missing Go dependency is the root cause, output EXACTLY ONE SHELL_EXEC task. -- The SHELL_EXEC command MUST be a runnable invocation with the ACTUAL package path (e.g. "go get github.com/docker/docker/client"), NOT a file path or angle-bracket placeholder. -- FORBIDDEN as SHELL_EXEC target: "go.mod", "go.sum", relative paths, or any text that is not a valid command. -- No brew, docker, or OS-level environment tasks. -- Keep the plan strictly at the code/dependency boundary.`, +%s`, contextStr, EnvironmentContext(), objective, + planTaskBlocks, ) } + +// PlanDirectMutationSystemPrompt returns a zero-prose system prompt for +// direct file mutations (e.g. refactor LICENSE, change config). Unlike +// PlanSystemPrompt, it omits all analysis sections and instructs the model +// to output ONLY the direct task item for execution. +func PlanDirectMutationSystemPrompt() string { + return "STRICT RULE: Direct file mutation detected.\n" + + "Output ONLY the direct task item for execution.\n" + + "No CONTEXT & ROLE, no FORENSIC HANDOFF, no preamble, no summary." +} diff --git a/internal/prompt/registry.go b/internal/prompt/registry.go index 19dbfe9..624a32a 100644 --- a/internal/prompt/registry.go +++ b/internal/prompt/registry.go @@ -8,39 +8,54 @@ import ( "github.com/PizenLabs/izen/internal/config" ) -// RuntimeFacts are facts supplied externally by the runtime (e.g. the engineer -// identity). The registry never contains engineering philosophy or mode logic; -// its only responsibility is composition: +// RuntimeFacts are externally supplied facts about the runtime environment. +// The registry's only responsibility is composition: // -// Common Contract + Mode Contract + Runtime Facts +// Identity Header + Common Contract + Mode Contract + Environment Context type RuntimeFacts struct { - // Username is the collaborating engineer's identity. Empty falls back to "developer". + // Username is the collaborating engineer's identity. Empty falls back to "Developer". Username string - // HostOS is the host operating system (runtime.GOOS) the agent runs on. - // When populated it anchors command generation to the real environment so - // the model does not hallucinate OS-specific commands (e.g. `apt-get` on - // macOS). Empty means "unknown" — the constraint is omitted. + // HostOS is the host operating system (runtime.GOOS). When set, anchors + // command generation to the real environment so the model never hallucinate + // OS-specific commands (e.g. `apt-get` on macOS). Empty → constraint omitted. HostOS string } -// Compose assembles the full system prompt for a mode from the constitutional -// common contract plus the mode's operational contract plus externally supplied -// runtime facts. The common and mode contracts are concatenated; no philosophy -// is duplicated because each lives in exactly one place. +// identityHeader returns the single, canonical engineer-identity statement. +// Called once per Compose invocation — never duplicated in individual contracts. +func identityHeader(username string) string { + return fmt.Sprintf( + "You are IZEN. The engineer collaborating with you is '%s'. "+ + "This is an invariant fact for the entire session: "+ + "NEVER say you don't know their name, ask them for it, or claim it wasn't provided. "+ + "When asked, identify them as '%s'.\n\n", + username, username, + ) +} + +// Compose assembles the full system prompt: +// +// Identity Header → Common Contract → Mode Contract → Environment Context (optional) +// +// Each section lives in exactly one place; nothing is duplicated. func Compose(modeContract string, facts RuntimeFacts) string { var b strings.Builder + username := config.SanitizeUsername(facts.Username) if username == "" { username = "Developer" } - fmt.Fprintf(&b, "You are IZEN. The human engineer you are collaborating with is named '%s'. This is a hard, invariant fact for the entire session — you MUST remember it and NEVER say you do not know their name, never ask them to tell you their name, and never claim the name was not provided. When asked about the user's identity, answer that they are '%s'.", username, username) + + b.WriteString(identityHeader(username)) b.WriteString(CommonContract()) b.WriteString("\n\n") b.WriteString(modeContract) + if facts.HostOS != "" { b.WriteString("\n\n") b.WriteString(EnvironmentContextForOS(facts.HostOS)) } + return b.String() } @@ -68,26 +83,16 @@ func InvestigateSystemPrompt() string { } // AskPromptHandoffSystemPrompt returns the composed system prompt for the -// $prompt handoff synthesis in ask mode. It combines the common contract with -// the handoff template so the LLM restructures raw chat history into the -// IZEN INTELLIGENT PROMPT HANDOFF PACK format. +// $prompt handoff synthesis in ask mode. Composes via the standard pipeline +// (no manual identity duplication). func AskPromptHandoffSystemPrompt(username string) string { if username == "" { username = "Developer" } - var b strings.Builder - fmt.Fprintf(&b, "You are IZEN. The human engineer you are collaborating with is named '%s'. This is a hard, invariant fact for the entire session — you MUST remember it and NEVER say you do not know their name, never ask them to tell you their name, and never claim the name was not provided. When asked about the user's identity, answer that they are '%s'.", username, username) - b.WriteString(CommonContract()) - b.WriteString("\n\n") - b.WriteString(AskPromptHandoffContract()) - if runtime.GOOS != "" { - b.WriteString("\n\n") - b.WriteString(EnvironmentContextForOS(runtime.GOOS)) - } - return b.String() + return Compose(AskPromptHandoffContract(), RuntimeFacts{Username: username, HostOS: runtime.GOOS}) } -// ForMode returns the composed system prompt for the named mode. +// ForMode returns the composed system prompt for the named mode with default identity. func ForMode(mode string) string { return ForModeWithUser(mode, "Developer") } @@ -111,24 +116,17 @@ func ForModeWithUser(mode, username string) string { } } -// BuildMessage composes a system prompt and a user message into a single string. -func BuildMessage(mode, userContent string) string { - sys := ForMode(mode) - if sys == "" { - return userContent - } - return fmt.Sprintf("System: %s\n\nUser: %s", sys, userContent) -} - -// IdentityStatement returns a short, standalone identity fact for injection -// directly into the message array on every LLM turn. This is separate from -// the system prompt so it lands near the user's current message in the -// model's context window — critical for smaller models that poorly attend -// to the system prompt. +// IdentityStatement returns a compact identity fact for injection into the +// message array on every LLM turn. This lands near the user's current message +// in the context window — useful for smaller models that poorly attend to the +// system prompt. Returns empty string when username is blank. func IdentityStatement(username string) string { name := config.SanitizeUsername(username) if name == "" { return "" } - return fmt.Sprintf("Remember: you are IZEN. The human talking to you is named '%s'. Never refer to yourself as %s. Always address the human as %s.", name, name, name) + return fmt.Sprintf( + "[IZEN] You are IZEN. The human talking to you is '%s'. Address them as '%s'.", + name, name, + ) } diff --git a/internal/prompt/review.go b/internal/prompt/review.go index 1bbc0a8..e0504fd 100644 --- a/internal/prompt/review.go +++ b/internal/prompt/review.go @@ -7,16 +7,12 @@ package prompt // Forbidden: implementation, mutation, patch generation. // Output: structured review. func ReviewContract() string { - return `MODE: /review — evaluate implementation quality. - -PURPOSE -- Review evaluates. It never modifies. Its purpose is trust, not automation. -- You are 100% read-only. + return `MODE: /review — evaluate implementation quality. Read-only. Never mutates. PERMISSIONS - Review code and verify correctness against the stated objective. - Analyze maintainability and regression risk. -- Detect risks and recommend tests or concrete improvements. +- Detect risks; recommend tests and concrete improvements. FORBIDDEN - Do NOT mutate code, generate patches, or implement changes. @@ -27,9 +23,8 @@ REVIEW PHILOSOPHY - Severity must be explicit. Lead with a clear verdict. OUTPUT — structured review - Summary: overall verdict and risk posture + Summary: overall verdict and risk posture Critical Findings: blockers that must be fixed before merge - Warnings: risks and maintainability concerns - Recommendations: concrete, actionable improvements and tests -- Recommend tests that would close gaps you identify.` + Warnings: risks and maintainability concerns + Recommendations: concrete, actionable improvements and tests` } diff --git a/internal/providers/claude.go b/internal/providers/claude.go index a16f853..ae63337 100644 --- a/internal/providers/claude.go +++ b/internal/providers/claude.go @@ -40,6 +40,7 @@ type claudeRequest struct { Model string `json:"model"` Messages []claudeMessage `json:"messages"` MaxTokens int `json:"max_tokens"` + Temperature float64 `json:"temperature,omitempty"` Stream bool `json:"stream"` System string `json:"system,omitempty"` StopSequences []string `json:"stop_sequences,omitempty"` @@ -101,6 +102,7 @@ func (p *ClaudeProvider) Execute(ctx context.Context, req ai.Request) (*ai.Respo Model: model, Messages: msgs, MaxTokens: maxTokens, + Temperature: req.Temperature, Stream: false, System: req.System, StopSequences: req.Stop, @@ -172,6 +174,7 @@ func (p *ClaudeProvider) ExecuteStream(ctx context.Context, req ai.Request) (io. Model: model, Messages: msgs, MaxTokens: maxTokens, + Temperature: req.Temperature, Stream: true, System: req.System, StopSequences: req.Stop, diff --git a/internal/providers/gemini.go b/internal/providers/gemini.go index 351dcd5..c2b6d66 100644 --- a/internal/providers/gemini.go +++ b/internal/providers/gemini.go @@ -52,7 +52,8 @@ type geminiSystemInstruction struct { } type geminiGenerationConfig struct { - MaxOutputTokens int `json:"maxOutputTokens,omitempty"` + MaxOutputTokens int `json:"maxOutputTokens,omitempty"` + Temperature float64 `json:"temperature,omitempty"` } type geminiResponse struct { @@ -123,6 +124,7 @@ func (p *GeminiProvider) Execute(ctx context.Context, req ai.Request) (*ai.Respo Stream: false, GenerationConfig: &geminiGenerationConfig{ MaxOutputTokens: maxTokens, + Temperature: req.Temperature, }, } @@ -197,6 +199,7 @@ func (p *GeminiProvider) ExecuteStream(ctx context.Context, req ai.Request) (io. Stream: true, GenerationConfig: &geminiGenerationConfig{ MaxOutputTokens: maxTokens, + Temperature: req.Temperature, }, } diff --git a/internal/providers/groq.go b/internal/providers/groq.go index 0008faa..d9fe771 100644 --- a/internal/providers/groq.go +++ b/internal/providers/groq.go @@ -45,11 +45,12 @@ func (p *GroqProvider) Execute(ctx context.Context, req ai.Request) (*ai.Respons msgs := p.buildMessages(req) body := groqRequest{ - Model: model, - Messages: msgs, - MaxTokens: req.MaxTokens, - Stop: req.Stop, - Stream: false, + Model: model, + Messages: msgs, + MaxTokens: req.MaxTokens, + Temperature: req.Temperature, + Stop: req.Stop, + Stream: false, } payload, err := json.Marshal(body) @@ -112,11 +113,12 @@ func (p *GroqProvider) ExecuteStream(ctx context.Context, req ai.Request) (io.Re msgs := p.buildMessages(req) body := groqRequest{ - Model: model, - Messages: msgs, - MaxTokens: req.MaxTokens, - Stop: req.Stop, - Stream: true, + Model: model, + Messages: msgs, + MaxTokens: req.MaxTokens, + Temperature: req.Temperature, + Stop: req.Stop, + Stream: true, } payload, err := json.Marshal(body) @@ -165,11 +167,12 @@ type groqMessage struct { } type groqRequest struct { - Model string `json:"model"` - Messages []groqMessage `json:"messages"` - MaxTokens int `json:"max_tokens,omitempty"` - Stop []string `json:"stop,omitempty"` - Stream bool `json:"stream"` + Model string `json:"model"` + Messages []groqMessage `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + Stop []string `json:"stop,omitempty"` + Stream bool `json:"stream"` } type groqResponse struct { diff --git a/internal/providers/ollama.go b/internal/providers/ollama.go index a9029cf..58d9e73 100644 --- a/internal/providers/ollama.go +++ b/internal/providers/ollama.go @@ -45,7 +45,8 @@ type ollamaRequest struct { Format string `json:"format,omitempty"` // "json" for structured output MaxTokens *int `json:"max_tokens,omitempty"` Options *struct { - NumPredict int `json:"num_predict"` + NumPredict int `json:"num_predict"` + Temperature float64 `json:"temperature,omitempty"` } `json:"options,omitempty"` } @@ -152,8 +153,9 @@ func (p *OllamaProvider) Execute(ctx context.Context, req ai.Request) (*ai.Respo Stream: false, MaxTokens: &maxTokens, Options: &struct { - NumPredict int `json:"num_predict"` - }{NumPredict: maxTokens}, + NumPredict int `json:"num_predict"` + Temperature float64 `json:"temperature,omitempty"` + }{NumPredict: maxTokens, Temperature: req.Temperature}, } if req.ResponseFormat != nil && req.ResponseFormat.Type == "json_object" { body.Format = "json" @@ -238,8 +240,9 @@ func (p *OllamaProvider) ExecuteStream(ctx context.Context, req ai.Request) (io. Stream: true, MaxTokens: &maxTokens, Options: &struct { - NumPredict int `json:"num_predict"` - }{NumPredict: maxTokens}, + NumPredict int `json:"num_predict"` + Temperature float64 `json:"temperature,omitempty"` + }{NumPredict: maxTokens, Temperature: req.Temperature}, } payload, err := json.Marshal(body) diff --git a/internal/providers/openai.go b/internal/providers/openai.go index 199d3f3..f131252 100644 --- a/internal/providers/openai.go +++ b/internal/providers/openai.go @@ -40,6 +40,7 @@ type openaiRequest struct { Model string `json:"model"` Messages []openaiMessage `json:"messages"` MaxTokens int `json:"max_tokens,omitempty"` + Temperature float64 `json:"temperature,omitempty"` Stop []string `json:"stop,omitempty"` Stream bool `json:"stream"` StreamOptions *streamOptions `json:"stream_options,omitempty"` @@ -97,11 +98,12 @@ func (p *OpenAIProvider) Execute(ctx context.Context, req ai.Request) (*ai.Respo msgs := p.buildMessages(req) body := openaiRequest{ - Model: model, - Messages: msgs, - MaxTokens: req.MaxTokens, - Stop: req.Stop, - Stream: false, + Model: model, + Messages: msgs, + MaxTokens: req.MaxTokens, + Temperature: req.Temperature, + Stop: req.Stop, + Stream: false, } payload, err := json.Marshal(body) @@ -167,6 +169,7 @@ func (p *OpenAIProvider) ExecuteStream(ctx context.Context, req ai.Request) (io. Model: model, Messages: msgs, MaxTokens: req.MaxTokens, + Temperature: req.Temperature, Stop: req.Stop, Stream: true, StreamOptions: &streamOptions{IncludeUsage: true}, diff --git a/internal/providers/openrouter.go b/internal/providers/openrouter.go index 3f94cc5..8e9e564 100644 --- a/internal/providers/openrouter.go +++ b/internal/providers/openrouter.go @@ -50,11 +50,12 @@ func (p *OpenRouterProvider) Execute(ctx context.Context, req ai.Request) (*ai.R msgs := p.buildMessages(req) body := openrouterRequest{ - Model: model, - Messages: msgs, - MaxTokens: req.MaxTokens, - Stop: req.Stop, - Stream: false, + Model: model, + Messages: msgs, + MaxTokens: req.MaxTokens, + Temperature: req.Temperature, + Stop: req.Stop, + Stream: false, } payload, err := json.Marshal(body) @@ -120,6 +121,7 @@ func (p *OpenRouterProvider) ExecuteStream(ctx context.Context, req ai.Request) Model: model, Messages: msgs, MaxTokens: req.MaxTokens, + Temperature: req.Temperature, Stop: req.Stop, Stream: true, StreamOptions: &streamOptions{IncludeUsage: true}, @@ -174,6 +176,7 @@ type openrouterRequest struct { Model string `json:"model"` Messages []openrouterMessage `json:"messages"` MaxTokens int `json:"max_tokens,omitempty"` + Temperature float64 `json:"temperature,omitempty"` Stop []string `json:"stop,omitempty"` Stream bool `json:"stream"` StreamOptions *streamOptions `json:"stream_options,omitempty"` diff --git a/internal/templates/extractor.go b/internal/templates/extractor.go new file mode 100644 index 0000000..357bf04 --- /dev/null +++ b/internal/templates/extractor.go @@ -0,0 +1,77 @@ +package templates + +import ( + "context" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +var ( + reSingleQuote = regexp.MustCompile(`'([^']*)'`) + reDoubleQuote = regexp.MustCompile(`"([^"]*)"`) + reAuthorKW = regexp.MustCompile(`(?i)\bauthors?\s+(\S+)`) + reByKW = regexp.MustCompile(`(?i)\bby\s+(\S+)`) + reYear = regexp.MustCompile(`\b(19[0-9][0-9]|20[0-9][0-9])\b`) +) + +type LicenseParams struct { + Year string + Author string +} + +func ExtractLicenseParams(prompt string) LicenseParams { + p := LicenseParams{ + Year: strconv.Itoa(time.Now().Year()), + Author: gitConfigUser(), + } + + if m := reSingleQuote.FindStringSubmatch(prompt); m != nil { + p.Author = m[1] + } else if m := reDoubleQuote.FindStringSubmatch(prompt); m != nil { + p.Author = m[1] + } else if m := reAuthorKW.FindStringSubmatch(prompt); m != nil { + candidate := m[1] + if !isYear(candidate) { + p.Author = candidate + } + } else if m := reByKW.FindStringSubmatch(prompt); m != nil { + candidate := m[1] + if !isYear(candidate) { + p.Author = candidate + } + } + + if m := reYear.FindString(prompt); m != "" { + p.Year = m + } + + return p +} + +func isYear(s string) bool { + if len(s) != 4 { + return false + } + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} + +func gitConfigUser() string { + ctx := context.Background() + out, err := exec.CommandContext(ctx, "git", "config", "user.name").Output() + if err != nil { + return "Author Name" + } + name := strings.TrimSpace(string(out)) + if name == "" { + return "Author Name" + } + return name +} diff --git a/internal/templates/licenses/apache-2.0.tpl b/internal/templates/licenses/apache-2.0.tpl new file mode 100644 index 0000000..6f765dd --- /dev/null +++ b/internal/templates/licenses/apache-2.0.tpl @@ -0,0 +1,188 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright (c) {{.Year}} {{.Author}} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + The License governs use of the software. \ No newline at end of file diff --git a/internal/templates/licenses/bsd-3-clause.tpl b/internal/templates/licenses/bsd-3-clause.tpl new file mode 100644 index 0000000..c9270db --- /dev/null +++ b/internal/templates/licenses/bsd-3-clause.tpl @@ -0,0 +1,30 @@ +BSD 3-Clause License + +Copyright (c) {{.Year}}, {{.Author}} +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + * Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/internal/templates/licenses/gpl-3.0.tpl b/internal/templates/licenses/gpl-3.0.tpl new file mode 100644 index 0000000..1d0c0bf --- /dev/null +++ b/internal/templates/licenses/gpl-3.0.tpl @@ -0,0 +1,17 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) {{.Year}}, {{.Author}} + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . \ No newline at end of file diff --git a/internal/templates/licenses/mit.tpl b/internal/templates/licenses/mit.tpl new file mode 100644 index 0000000..1d137e8 --- /dev/null +++ b/internal/templates/licenses/mit.tpl @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) {{.Year}} {{.Author}} + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/internal/templates/registry.go b/internal/templates/registry.go new file mode 100644 index 0000000..860989f --- /dev/null +++ b/internal/templates/registry.go @@ -0,0 +1,66 @@ +package templates + +import ( + "bytes" + "embed" + "strings" + "text/template" +) + +//go:embed licenses/*.tpl +var embedFS embed.FS + +var registry map[string]*template.Template + +func init() { + registry = make(map[string]*template.Template) + licenses, _ := embedFS.ReadDir("licenses") + for _, entry := range licenses { + name := entry.Name() + data, _ := embedFS.ReadFile("licenses/" + name) + tmpl, err := template.New(name).Parse(string(data)) + if err != nil { + continue + } + key := licenseKey(name) + registry[key] = tmpl + } +} + +func licenseKey(filename string) string { + s := strings.TrimSuffix(filename, ".tpl") + return registryKey(s) +} + +func registryKey(name string) string { + s := strings.ReplaceAll(name, "-", " ") + return strings.ToLower(strings.TrimSpace(s)) +} + +func RenderLicense(licenseType, prompt string) (string, bool) { + key := registryKey(licenseType) + tmpl, ok := registry[key] + if !ok { + return "", false + } + params := ExtractLicenseParams(prompt) + var buf bytes.Buffer + if err := tmpl.Execute(&buf, params); err != nil { + return "", false + } + return buf.String(), true +} + +func ReadLicenseTemplate(licenseType string) (string, bool) { + key := registryKey(licenseType) + _, ok := registry[key] + if !ok { + return "", false + } + licenseName := strings.ReplaceAll(key, " ", "-") + ".tpl" + data, err := embedFS.ReadFile("licenses/" + licenseName) + if err != nil { + return "", false + } + return string(data), true +} diff --git a/internal/ui/commands.go b/internal/ui/commands.go index dac78ec..c8ba0aa 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -36,6 +36,7 @@ import ( "github.com/PizenLabs/izen/internal/retrieval" riview "github.com/PizenLabs/izen/internal/review" "github.com/PizenLabs/izen/internal/session" + "github.com/PizenLabs/izen/internal/templates" ) var validSystemCommands = map[string]struct{}{ @@ -209,6 +210,29 @@ func (m *model) handleInput(line string) tea.Cmd { } rawInput := strings.TrimSpace(line[8:]) + // ── COMPRESSOR FAST-TRACK ────────────────────────── + // Check the prompt compressor first. If it signals a direct + // mutation (BypassInvest=true) with a target file, skip ALL + // Architect prompts, skip /investigate mode routing entirely, + // and route directly to BUILD with a staged FILE_MUTATE task. + if compressed := gateway.CompressPrompt(rawInput); compressed != nil && compressed.BypassInvest && compressed.Target != "" { + target := command.FallbackPlanTarget{ + File: compressed.Target, + Description: rawInput, + TaskType: "FILE_MUTATE", + } + m.push(roleSystem, accentStyle.Render("[Fast-Track] Direct file mutation detected by compressor. Bypassing architect analysis.")) + m.refreshViewportContent() + m.Viewport.GotoBottom() + tasks := command.GenerateFallbackPlan(target) + return func() tea.Msg { + return planResultMsg{ + Tasks: tasks, + IsFastTrack: true, + } + } + } + // ── INTENT PRE-GUARD: Fast-track direct file mutations ────────── // Inspect the raw input before dispatching to the Senior Architect // pipeline. If the user is requesting a simple single-file mutation @@ -1340,7 +1364,8 @@ func (m *model) handleCommand(cmd string) tea.Cmd { m.push(roleSystem, infoStyle.Render(" /undo /commit /checkpoint /arch")) m.push(roleSystem, infoStyle.Render(" /objective approve approve budget-guarded objective")) m.push(roleSystem, infoStyle.Render(" /usage inspect token usage and provider status")) - m.push(roleSystem, infoStyle.Render(" /model interactive model picker (fuzzy search)")) + m.push(roleSystem, infoStyle.Render(" /model interactive model picker (fuzzy search)")) + m.push(roleSystem, infoStyle.Render(" /model switch active model directly (e.g. /model claude-3-5-sonnet)")) m.push(roleSystem, infoStyle.Render(" ! run a shell command")) m.push(roleSystem, "") m.push(roleSystem, labelBoldStyle.Render("ask sub-commands ($)")) @@ -1395,6 +1420,14 @@ func (m *model) handleCommand(cmd string) tea.Cmd { return m.modelPicker.LoadModels(providers) + case strings.HasPrefix(cmd, "/model "): + modelArg := strings.TrimSpace(strings.TrimPrefix(cmd, "/model")) + if modelArg == "" { + m.push(roleSystem, infoStyle.Render("usage: /model — switch active model directly")) + return nil + } + return m.switchModelDirect(modelArg) + case strings.HasPrefix(cmd, "/objective"): objArg := strings.TrimSpace(strings.TrimPrefix(cmd, "/objective")) if strings.EqualFold(objArg, "approve") { @@ -1553,6 +1586,96 @@ func (m *model) handleCommand(cmd string) tea.Cmd { return nil } +// switchModelDirect handles /model for direct, non-interactive +// model switching. It resolves the model name against the configured providers +// and sets it as the session-level override (m.sessionModel). +func (m *model) switchModelDirect(modelName string) tea.Cmd { + modelName = strings.TrimSpace(modelName) + if modelName == "" { + m.push(roleSystem, infoStyle.Render("usage: /model — switch active model directly")) + return nil + } + + // Check model tier configuration for active_override or tier-default resolution. + // If the model name matches an active_override in any tier, use that tier's + // provider association for routing. + resolvedProvider := "" + if m.cfg.Models.Tiers != nil { + for _, tc := range m.cfg.Models.Tiers { + if tc.ActiveOverride == modelName || tc.Model == modelName { + if tc.Provider != "" { + resolvedProvider = tc.Provider + } + break + } + } + } + + // Set the session model override immediately so the status bar reflects + // the change before the provider switch completes. + m.sessionModel = modelName + m.cfg.Models.SessionModel = modelName + + // Determine the provider for this model. If we couldn't resolve it from + // tier config, try to infer from the model name format. + if resolvedProvider == "" { + resolvedProvider = m.inferProviderFromModel(modelName) + } + + // If the provider changed, switch providers. + if resolvedProvider != "" { + currentProvider := "" + if m.provider != nil { + currentProvider = m.provider.Name() + } + if resolvedProvider != currentProvider { + // Validate the provider exists in config. + if _, ok := m.cfg.AI.Providers[resolvedProvider]; ok || resolvedProvider == "ollama" { + m.push(roleSystem, infoStyle.Render(fmt.Sprintf("switching to provider %q for model %q...", resolvedProvider, modelName))) + m.refreshViewportContent() + m.Viewport.GotoBottom() + return tea.Batch( + m.switchProvider(resolvedProvider), + func() tea.Msg { + return providerSwitchMsg{name: resolvedProvider} + }, + ) + } + } + } + + m.ti.Focus() + m.push(roleSystem, accentStyle.Render(fmt.Sprintf("✓ Model set to %s", modelName))) + m.refreshViewportContent() + m.Viewport.GotoBottom() + return nil +} + +// inferProviderFromModel tries to infer the provider from the model name format. +// Model names with "/" are treated as openrouter-style (provider/model). +// Ollama models (e.g. qwen2.5-coder:7b, llama3:8b) default to ollama. +func (m *model) inferProviderFromModel(modelName string) string { + if strings.Contains(modelName, "/") { + return "openrouter" + } + // Check if the model name looks like an Ollama model (contains ":" version tag) + // or is a known local model pattern. Default to ollama for non-cloud models. + if strings.Contains(modelName, ":") { + return "ollama" + } + // For bare model names without version tags, check if it's a known cloud model. + cloudModels := map[string]bool{ + "gpt-4o": true, "gpt-4": true, "gpt-4-turbo": true, "gpt-3.5-turbo": true, + "claude-sonnet-4-20250514": true, "claude-3-5-sonnet": true, "claude-3-opus": true, + "llama-3.3-70b-versatile": true, "llama3": true, "llama3.1": true, "llama3.2": true, + "mistral": true, "mixtral": true, + } + if cloudModels[modelName] { + return "openai" + } + return "ollama" +} + func (m *model) startModeTransition(target modes.Mode) { m.lineAnimTargetMode = target m.lineAnimProgress = 0.0 @@ -2052,12 +2175,13 @@ func (m *model) proposeHotfixPatch(task *plan.Task) tea.Cmd { diff := computeUnifiedDiff(task.Target, orig, resolved) patch := &execution.Patch{ - ID: fmt.Sprintf("hotfix-%d", task.StepNum), - File: task.Target, - Original: orig, - Modified: cleaned, - TaskID: task.StepNum, - ContextID: m.sess.ContextID, + ID: fmt.Sprintf("hotfix-%d", task.StepNum), + File: task.Target, + Original: orig, + Modified: cleaned, + TaskID: task.StepNum, + ContextID: m.sess.ContextID, + IsFullRewrite: true, } return hotfixProposalMsg{ @@ -2091,12 +2215,13 @@ func (m *model) proposeStdlibBuildPatch(task *plan.Task) tea.Cmd { diff := computeUnifiedDiff(task.Target, orig, modified) patch := &execution.Patch{ - ID: fmt.Sprintf("stdlib-%d", task.StepNum), - File: task.Target, - Original: orig, - Modified: modified, - TaskID: task.StepNum, - ContextID: m.sess.ContextID, + ID: fmt.Sprintf("stdlib-%d", task.StepNum), + File: task.Target, + Original: orig, + Modified: modified, + TaskID: task.StepNum, + ContextID: m.sess.ContextID, + IsFullRewrite: true, } return buildProposalReadyMsg{ @@ -2191,12 +2316,13 @@ func (m *model) proposeBuildPatch(task *plan.Task) tea.Cmd { diff := computeUnifiedDiff(task.Target, orig, resolved) patch := &execution.Patch{ - ID: fmt.Sprintf("build-%d", task.StepNum), - File: task.Target, - Original: orig, - Modified: cleaned, - TaskID: task.StepNum, - ContextID: m.sess.ContextID, + ID: fmt.Sprintf("build-%d", task.StepNum), + File: task.Target, + Original: orig, + Modified: cleaned, + TaskID: task.StepNum, + ContextID: m.sess.ContextID, + IsFullRewrite: task.IsHardcoded, } return buildProposalReadyMsg{ @@ -2211,11 +2337,308 @@ func (m *model) proposeBuildPatch(task *plan.Task) tea.Cmd { } } -// applyHotfixPatch applies a pre-generated $hot patch through the execution -// engine's PatchManager — never via the conversational stream. It returns a -// buildResultMsg so the standard update.go handler restores the stashed plan -// and freezes the pipeline to PAUSED afterwards. On failure the task is marked -// failed and the buildResultMsg handler rolls back any partial mutation. +// proposeTrivialCreatePatch generates a trivial template file (LICENSE, +// .gitignore, .env) locally using Go string templates — zero cloud tokens. +// It bypasses the LLM entirely and returns a buildProposalReadyMsg with the +// generated content and a unified diff against any existing file content. +func (m *model) proposeTrivialCreatePatch(task *plan.Task) tea.Cmd { + return func() tea.Msg { + canonicalTarget := gateway.CanonicalizeFileName(task.Target) + task.Target = canonicalTarget + var orig string + if data, rerr := os.ReadFile(canonicalTarget); rerr == nil { + orig = string(data) + } + + description := task.Description + content := generateTrivialContent(canonicalTarget, description) + + cleaned := execution.SanitizeLLMResponse(content) + diff := computeUnifiedDiff(canonicalTarget, orig, cleaned) + + patch := &execution.Patch{ + ID: fmt.Sprintf("template-%d", task.StepNum), + File: canonicalTarget, + Original: orig, + Modified: cleaned, + TaskID: task.StepNum, + ContextID: m.sess.ContextID, + IsFullRewrite: true, + } + + return buildProposalReadyMsg{ + Task: task, + Patch: patch, + Diff: diff, + Output: cleaned, + } + } +} + +// generateTrivialContent generates the file content for a trivial template +// file (LICENSE, .gitignore, .env) from the user's description string. +// Returns empty string if the target is not recognized. +func generateTrivialContent(target, description string) string { + base := strings.ToLower(target) + base = strings.TrimSuffix(base, ".md") + + switch base { + case "license", "licence": + licenseType := detectLicenseType(description) + content, ok := templates.RenderLicense(licenseType, description) + if !ok { + return "" + } + return content + case ".gitignore", "gitignore": + return generateGitignore() + case ".env", "env", ".env.example", "env.example": + return generateEnv() + default: + return "" + } +} + +func detectLicenseType(description string) string { + lower := strings.ToLower(description) + switch { + case strings.Contains(lower, "apache"): + return "apache-2.0" + case strings.Contains(lower, "gpl"): + return "gpl-3.0" + case strings.Contains(lower, "bsd"): + return "bsd-3-clause" + default: + return "mit" + } +} + +// generateGitignore returns a basic Go .gitignore template. +func generateGitignore() string { + return `# Dependencies +vendor/ + +# Build output +bin/ +dist/ +build/ +*.exe +*.dll +*.so +*.dylib + +# Go +*.test +*.out +*.prof +*.test.exe +go.work + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +.env.*.local +` +} + +// generateEnv returns a basic .env template. +func generateEnv() string { + return `# Application +APP_ENV=development +APP_DEBUG=true +APP_PORT=8080 + +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=myapp +DB_USER=user +DB_PASSWORD= + +# API Keys (fill in your own) +API_KEY= +SECRET_KEY= +` +} + +func isYear(s string) bool { + if len(s) != 4 { + return false + } + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + n := 0 + for _, c := range s { + n = n*10 + int(c-'0') + } + return n >= 1900 && n <= 2099 +} + +var customizationDirectivePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)\b(?:author|by|copyright|holder|organization)\b`), + regexp.MustCompile(`\b(19[0-9][0-9]|20[0-9][0-9])\b`), + regexp.MustCompile(`['"][^'"]+['"]`), + regexp.MustCompile(`(?i)\brefactor|convert|replace|change|update|modify\b`), +} + +func hasCustomizationDirectives(description string) bool { + if description == "" { + return false + } + lower := strings.ToLower(description) + for _, pat := range customizationDirectivePatterns { + if pat.MatchString(lower) { + return true + } + } + return false +} + +// proposeHybridTemplatePatch generates a patch for a trivial template file +// (LICENSE, .gitignore, .env) by resolving the reference template text and +// injecting it into the LLM context as a reference, instructing the model to +// apply the user's specific modifications. This consumes LLM tokens but allows +// the model to handle author names, year overrides, and other customizations +// that the static Go template renderer cannot support. +// +// If the reference text cannot be resolved (unexpected target) the function +// falls through to generateTrivialContent — the static template renderer. +func (m *model) proposeHybridTemplatePatch(task *plan.Task) tea.Cmd { + return func() tea.Msg { + if m.provider == nil { + return buildProposalReadyMsg{Err: fmt.Errorf("build execution error: no provider configured")} + } + + canonicalTarget := gateway.CanonicalizeFileName(task.Target) + task.Target = canonicalTarget + + var orig string + if data, rerr := os.ReadFile(canonicalTarget); rerr == nil { + orig = string(data) + } + + referenceText := resolveReferenceTemplate(canonicalTarget, task.Description) + if referenceText == "" { + content := generateTrivialContent(canonicalTarget, task.Description) + cleaned := execution.SanitizeLLMResponse(content) + diff := computeUnifiedDiff(canonicalTarget, orig, cleaned) + return buildProposalReadyMsg{ + Task: task, + Patch: &execution.Patch{ + ID: fmt.Sprintf("hybrid-%d", task.StepNum), + File: canonicalTarget, + Original: orig, + Modified: cleaned, + TaskID: task.StepNum, + ContextID: m.sess.ContextID, + IsFullRewrite: true, + }, + Diff: diff, + Output: cleaned, + } + } + + handoff := ctxpkg.SanitizeBuildHandoff(task, "") + handoff += "\n\n### REFERENCE TEMPLATE\n" + handoff += "Below is the base template text. Apply the user's specific modifications " + handoff += "(e.g., author name, year, or content changes) to this text and output " + handoff += "the final updated content as a FILE_CREATE block.\n\n" + handoff += "```\n" + referenceText + "\n```\n" + if orig != "" { + handoff += "\n### EXISTING FILE CONTENT\n```\n" + orig + "\n```\n" + handoff += "\nThe file already exists. Overwrite it with the modified template content." + } + handoff += "\n\n### USER REQUEST\n" + task.Description + handoff += "\n\nOutput a <<<<<<< FILE_CREATE: " + canonicalTarget + " block with the final content." + + system := prompt.BuildContract() + + req := ai.Request{ + Model: m.cfg.ActiveModelName(), + System: system, + Stream: false, + MaxTokens: 2000, + Messages: []ai.Message{{Role: "user", Content: handoff}}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + resp, err := m.provider.Execute(ctx, req) + cancel() + if err != nil { + return buildProposalReadyMsg{Err: fmt.Errorf("hybrid template patch failed: %w", err)} + } + if resp == nil || strings.TrimSpace(resp.Content) == "" { + return buildProposalReadyMsg{Err: fmt.Errorf("hybrid template patch returned empty output")} + } + + cleaned := sanitizeFileOutput(resp.Content) + resolved := execution.ResolveModifiedContent(orig, resp.Content) + if resolved == "" { + resolved = cleaned + } + + diff := computeUnifiedDiff(canonicalTarget, orig, resolved) + patch := &execution.Patch{ + ID: fmt.Sprintf("hybrid-%d", task.StepNum), + File: canonicalTarget, + Original: orig, + Modified: cleaned, + TaskID: task.StepNum, + ContextID: m.sess.ContextID, + IsFullRewrite: true, + } + + return buildProposalReadyMsg{ + Task: task, + Patch: patch, + Diff: diff, + Output: resp.Content, + } + } +} + +// resolveReferenceTemplate resolves the reference template text for a given +// trivial template target. For LICENSE files it reads from the embedded license +// registry; for .gitignore / .env it returns the base generated content. +// Returns "" when the target is not a trivial template file. +func resolveReferenceTemplate(target, description string) string { + base := strings.ToLower(target) + base = strings.TrimSuffix(base, ".md") + + switch base { + case "license", "licence": + licenseType := detectLicenseType(description) + if licenseType == "" { + licenseType = "mit" + } + text, ok := templates.ReadLicenseTemplate(licenseType) + if ok { + return text + } + return "" + case ".gitignore", "gitignore": + return generateGitignore() + case ".env", "env", ".env.example", "env.example": + return generateEnv() + default: + return "" + } +} + func (m *model) applyHotfixPatch(task *plan.Task, patch *execution.Patch) tea.Cmd { return func() tea.Msg { if applyErr := m.execEng.Patches.Apply(patch); applyErr != nil { @@ -2562,6 +2985,27 @@ func (m *model) handleBuildRun(stepNum int) tea.Cmd { // the pipeline in StateAwaitingApproval and renders a unified diff for // explicit authorization (Alt+A / Alt+L / Alt+R). if targetTask.Type == "FILE_MUTATE" || targetTask.Type == "GIT_ACTION" { + // ── TRIVIAL TEMPLATE CREATE (local generation, 0 cloud tokens) ─ + // If the task targets a trivial template file (LICENSE, .gitignore, + // .env) AND the description contains no customization directives + // (author name, year, refactor, etc.), generate it locally using + // Go string templates. This bypasses the LLM entirely — zero HTTP + // calls, zero cloud tokens consumed. + if targetTask.IsHardcoded && gateway.IsTrivialCreateTarget(targetTask.Target) { + if hasCustomizationDirectives(targetTask.Description) { + return tea.Batch( + func() tea.Msg { return agentStartMsg{label: "hybrid template"} }, + m.proposeHybridTemplatePatch(targetTask), + m.spinnerTickCmd(), + ) + } + return tea.Batch( + func() tea.Msg { return agentStartMsg{label: "template"} }, + m.proposeTrivialCreatePatch(targetTask), + m.spinnerTickCmd(), + ) + } + // ── DETERMINISTIC STDLIB FIX (no LLM) ────────────────────────── // Hardcoded stdlib case-correction tasks carry fix parameters in // the Solution field ("STDLIB:symbol:pkgName:importPath"). Apply diff --git a/internal/ui/proposals.go b/internal/ui/proposals.go index 922bbf3..8ec1835 100644 --- a/internal/ui/proposals.go +++ b/internal/ui/proposals.go @@ -446,10 +446,11 @@ func (m *model) applyProposalCmd(p SemanticProposal) tea.Cmd { modified = p.Patch.Modified } patch := &execution.Patch{ - ID: p.ID, - File: p.Target.QualifiedName, - Modified: modified, - TaskID: m.currentBuildTaskID, + ID: p.ID, + File: p.Target.QualifiedName, + Modified: modified, + TaskID: m.currentBuildTaskID, + IsFullRewrite: p.Patch != nil && p.Patch.IsFullRewrite, } orig, err := os.ReadFile(p.Target.QualifiedName) if err == nil { @@ -503,10 +504,11 @@ func (m *model) applyAllProposalsCmd() tea.Cmd { modified = p.Patch.Modified } patch := &execution.Patch{ - ID: p.ID, - File: p.Target.QualifiedName, - Modified: modified, - TaskID: m.currentBuildTaskID, + ID: p.ID, + File: p.Target.QualifiedName, + Modified: modified, + TaskID: m.currentBuildTaskID, + IsFullRewrite: p.Patch != nil && p.Patch.IsFullRewrite, } orig, err := os.ReadFile(p.Target.QualifiedName) if err == nil { diff --git a/internal/ui/stream.go b/internal/ui/stream.go index 8862966..b5a3e01 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -14,6 +14,7 @@ import ( "github.com/PizenLabs/izen/internal/agents" "github.com/PizenLabs/izen/internal/ai" "github.com/PizenLabs/izen/internal/domain" + "github.com/PizenLabs/izen/internal/gateway" "github.com/PizenLabs/izen/internal/modes" "github.com/PizenLabs/izen/internal/modes/plan" "github.com/PizenLabs/izen/internal/prompt" @@ -121,27 +122,31 @@ func (m *model) streamCmd(content string) tea.Cmd { if uname == "" { uname = m.userName } - systemPrompt := prompt.ForModeWithUser(m.resolver.Current().String(), uname) - - // Inject identity context directly into the messages array so it lands - // near the user's current turn in the model's context window. This is - // critical for smaller models (e.g. Qwen 2.5 7B) that poorly attend to - // the system prompt but follow instructions embedded in the chat flow. - if identityLine := prompt.IdentityStatement(uname); identityLine != "" { - identityMsg := ai.Message{Role: "system", Content: identityLine} - // Insert right before the current user message - beforeUser := msgs[:len(msgs)-1] - rest := msgs[len(msgs)-1:] - msgs = append(append(beforeUser, identityMsg), rest...) + + var systemPrompt string + var maxTokens int + + if gateway.IsCasualChat(content) { + systemPrompt = gateway.CasualChatSystemPrompt() + maxTokens = gateway.CasualChatMaxTokens() + } else { + systemPrompt = prompt.ForModeWithUser(m.resolver.Current().String(), uname) + if identityLine := prompt.IdentityStatement(uname); identityLine != "" { + identityMsg := ai.Message{Role: "system", Content: identityLine} + beforeUser := msgs[:len(msgs)-1] + rest := msgs[len(msgs)-1:] + msgs = append(append(beforeUser, identityMsg), rest...) + } } debugLogPayload(content, msgs) req := ai.Request{ - Model: m.cfg.ActiveModelName(), - Messages: msgs, - Stream: true, - System: systemPrompt, + Model: m.cfg.ActiveModelName(), + Messages: msgs, + Stream: true, + System: systemPrompt, + MaxTokens: maxTokens, } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) diff --git a/internal/ui/update_test.go b/internal/ui/update_test.go index 489c6b5..c829890 100644 --- a/internal/ui/update_test.go +++ b/internal/ui/update_test.go @@ -1,29 +1,139 @@ package ui import ( + "strings" "testing" - "github.com/PizenLabs/izen/internal/modes" + "github.com/PizenLabs/izen/internal/templates" ) -func TestUpdateSuggestionsRebuildsViewportHeightImmediately(t *testing.T) { - m := &model{ - width: 100, - height: 40, - resolver: modes.NewResolver(), - showBanner: false, - ledger: NewContextLedger(), +func TestRenderMITLicense(t *testing.T) { + tests := []struct { + name string + description string + wantAuthor string + wantYear string + }{ + { + name: "quoted author with year", + description: `create MIT LICENSE with author 'TOMATO' 2026`, + wantAuthor: "TOMATO", + wantYear: "2026", + }, + { + name: "unquoted author after author keyword", + description: "MIT LICENSE author TOMATO 2026", + wantAuthor: "TOMATO", + wantYear: "2026", + }, + { + name: "no author uses git config default", + description: "create MIT LICENSE", + wantAuthor: "", + wantYear: "2026", + }, + { + name: "double quoted author", + description: `create MIT license with author "Jane Doe" 2025`, + wantAuthor: "Jane Doe", + wantYear: "2025", + }, } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + content, ok := templates.RenderLicense("mit", tc.description) + if !ok { + t.Fatal("RenderLicense returned false for mit type") + } + if !strings.Contains(content, "MIT License") { + t.Errorf("content missing MIT License header") + } + if !strings.Contains(content, tc.wantYear) { + t.Errorf("content missing year %q", tc.wantYear) + } + if tc.wantAuthor != "" && !strings.Contains(content, tc.wantAuthor) { + t.Errorf("content missing author %q\ncontent:\n%s", tc.wantAuthor, content) + } + }) + } +} - m.input.WriteString("/") - m.updateSuggestions() +func TestGenerateTrivialContent(t *testing.T) { + tests := []struct { + name string + target string + desc string + wantErr bool + }{ + {"MIT LICENSE", "LICENSE", "MIT LICENSE author TOMATO 2026", false}, + {"lowercase license", "license", "create license", false}, + {".gitignore", ".gitignore", "create gitignore", false}, + {".env", ".env", "create env file", false}, + {"unknown returns empty", "main.go", "", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := generateTrivialContent(tc.target, tc.desc) + if tc.wantErr && got != "" { + t.Errorf("expected empty, got %q", got) + } + if !tc.wantErr && got == "" { + t.Errorf("expected non-empty content") + } + }) + } +} - if !m.showSuggestions { - t.Fatal("expected suggestions to be visible after slash input") +func TestIsYear(t *testing.T) { + tests := []struct { + s string + want bool + }{ + {"2026", true}, + {"1999", true}, + {"2099", true}, + {"1800", false}, + {"0000", false}, + {"abc", false}, + {"20", false}, + {"", false}, + } + for _, tc := range tests { + t.Run(tc.s, func(t *testing.T) { + got := isYear(tc.s) + if got != tc.want { + t.Errorf("isYear(%q) = %v, want %v", tc.s, got, tc.want) + } + }) } +} + +func TestGenerateTrivialContentCanonical(t *testing.T) { + tests := []struct { + name string + target string + desc string + }{ + {"lowercase license", "license", "create license"}, + {"uppercase LICENSE", "LICENSE", "create LICENSE"}, + {"lowercase gitignore", "gitignore", "create gitignore"}, + {"uppercase .GITIGNORE", ".GITIGNORE", "create .gitignore"}, + {"lowercase env", "env", "create env"}, + {"uppercase .ENV", ".ENV", "create .env"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := generateTrivialContent(tc.target, tc.desc) + if got == "" { + t.Errorf("expected non-empty content for target %q", tc.target) + } + }) + } +} - m.dismissSuggestions() - if m.showSuggestions { - t.Fatal("expected suggestions to be hidden after dismiss") +func TestRenderLicenseFallback(t *testing.T) { + _, ok := templates.RenderLicense("unknown-xyz", "some description") + if ok { + t.Error("expected false for unknown license type") } }