diff --git a/README.md b/README.md index f60fefa..3179c9a 100644 --- a/README.md +++ b/README.md @@ -430,8 +430,11 @@ thresholds: # Embedding configuration embeddings: - provider: local # type: "local" | "openai" + provider: local # type: "local" | "openai" | "ollama" openai_api_key: sk-... # type: string — only needed if provider is "openai" + ollama_base_url: http://localhost:11434 # type: string — only needed if provider is "ollama" + ollama_model: nomic-embed-text # type: string — Ollama model name (default: nomic-embed-text) + ollama_dimensions: 768 # type: number — embedding dimensions (default: 768) ``` ### `.preflight/triage.yml` @@ -495,7 +498,9 @@ Manual contract definitions that supplement auto-extraction: | `CLAUDE_PROJECT_DIR` | Project root to monitor | **Required** | | `OPENAI_API_KEY` | OpenAI key for embeddings | Uses local Xenova | | `PREFLIGHT_RELATED` | Comma-separated related project paths | None | -| `EMBEDDING_PROVIDER` | `local` or `openai` | `local` | +| `EMBEDDING_PROVIDER` | `local`, `openai`, or `ollama` | `local` | +| `OLLAMA_BASE_URL` | Ollama server URL | `http://localhost:11434` | +| `OLLAMA_MODEL` | Ollama embedding model | `nomic-embed-text` | | `PROMPT_DISCIPLINE_PROFILE` | `minimal`, `standard`, or `full` | `standard` | Environment variables are **fallbacks** — `.preflight/` config takes precedence when present. diff --git a/src/lib/embeddings.ts b/src/lib/embeddings.ts index 69b5883..c9c0653 100644 --- a/src/lib/embeddings.ts +++ b/src/lib/embeddings.ts @@ -102,11 +102,63 @@ class OpenAIEmbeddingProvider implements EmbeddingProvider { } } +// --- Ollama Provider --- + +class OllamaEmbeddingProvider implements EmbeddingProvider { + dimensions: number; + private baseUrl: string; + private model: string; + + constructor(options?: { baseUrl?: string; model?: string; dimensions?: number }) { + this.baseUrl = (options?.baseUrl ?? "http://localhost:11434").replace(/\/+$/, ""); + this.model = options?.model ?? "nomic-embed-text"; + // Default dimensions for nomic-embed-text; override via config if using a different model + this.dimensions = options?.dimensions ?? 768; + } + + async embed(text: string): Promise { + const processed = preprocessText(text); + const resp = await fetch(`${this.baseUrl}/api/embed`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: this.model, input: processed }), + }); + + if (!resp.ok) { + const err = await resp.text(); + throw new Error(`Ollama embeddings error ${resp.status}: ${err}`); + } + + const data = await resp.json(); + return data.embeddings[0]; + } + + async embedBatch(texts: string[]): Promise { + const processed = texts.map(preprocessText); + const resp = await fetch(`${this.baseUrl}/api/embed`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: this.model, input: processed }), + }); + + if (!resp.ok) { + const err = await resp.text(); + throw new Error(`Ollama embeddings error ${resp.status}: ${err}`); + } + + const data = await resp.json(); + return data.embeddings; + } +} + // --- Factory --- export interface EmbeddingConfig { - provider: "local" | "openai"; + provider: "local" | "openai" | "ollama"; apiKey?: string; + ollamaBaseUrl?: string; + ollamaModel?: string; + ollamaDimensions?: number; } export function createEmbeddingProvider(config: EmbeddingConfig): EmbeddingProvider { @@ -114,5 +166,12 @@ export function createEmbeddingProvider(config: EmbeddingConfig): EmbeddingProvi if (!config.apiKey) throw new Error("OpenAI API key required for openai embedding provider"); return new OpenAIEmbeddingProvider(config.apiKey); } + if (config.provider === "ollama") { + return new OllamaEmbeddingProvider({ + baseUrl: config.ollamaBaseUrl, + model: config.ollamaModel, + dimensions: config.ollamaDimensions, + }); + } return new LocalEmbeddingProvider(); } diff --git a/src/lib/preflight-helpers.ts b/src/lib/preflight-helpers.ts new file mode 100644 index 0000000..43080f9 --- /dev/null +++ b/src/lib/preflight-helpers.ts @@ -0,0 +1,74 @@ +/** + * Pure helper functions for the preflight_check unified entry point. + * Extracted for testability — no side effects, no external dependencies + * (except file system checks which are isolated). + */ + +import { existsSync, statSync } from "fs"; +import { resolve } from "path"; + +/** Extract file paths from prompt text */ +export function extractFilePaths(prompt: string): string[] { + // Match standard paths (src/auth/jwt.ts) and dotfiles (.env, .gitignore) + const standard = prompt.match(/[\w\-./\\]+\.\w{1,6}/g) || []; + const dotfiles = prompt.match(/(?:^|[\s,:(])(\.[a-zA-Z][\w.-]*)/g) || []; + const cleaned = dotfiles.map(m => m.replace(/^[\s,:(]+/, "")); + return [...new Set([...standard, ...cleaned])]; +} + +/** Verify files exist and return status lines. projectDir scopes the check. */ +export function verifyFiles(paths: string[], projectDir: string): string[] { + const lines: string[] = []; + for (const p of paths) { + const abs = resolve(projectDir, p); + if (!abs.startsWith(resolve(projectDir))) continue; // path traversal guard + if (existsSync(abs)) { + const s = statSync(abs); + lines.push(`✅ \`${p}\` — ${s.size} bytes, modified ${s.mtime.toISOString().slice(0, 16)}`); + } else { + lines.push(`❌ \`${p}\` — not found`); + } + } + return lines; +} + +/** Detect ambiguity signals in a prompt */ +export function detectAmbiguity(prompt: string): string[] { + const issues: string[] = []; + const filePaths = extractFilePaths(prompt); + if (/\b(it|them|the thing|that|those|this|these)\b/i.test(prompt)) { + issues.push("Contains vague pronouns — clarify what 'it'/'them' refers to"); + } + if (/\b(fix|update|change|refactor|improve)\b/i.test(prompt) && !filePaths.length) { + issues.push("Vague verb without specific file targets"); + } + if (prompt.trim().length < 40) { + issues.push("Very short prompt — likely missing context"); + } + return issues; +} + +/** Estimate scope complexity from file paths */ +export function estimateComplexity(filePaths: string[]): "SMALL" | "MEDIUM" | "LARGE" { + const hasMultipleFiles = filePaths.length > 3; + const hasMultipleDirs = new Set(filePaths.map(f => f.split("/")[0])).size > 2; + return hasMultipleFiles && hasMultipleDirs ? "LARGE" : filePaths.length > 1 ? "MEDIUM" : "SMALL"; +} + +/** Split prompt into sub-tasks for sequencing */ +export function splitSubtasks(prompt: string): { step: string; risk: string }[] { + const parts = prompt + .split(/\b(?:then|after that|next|finally)\b|(?:,\s*and\s+)|(?:\band\b(?=\s+(?:update|add|remove|create|fix|change|refactor|implement|deploy)))/i) + .map(s => s.trim()) + .filter(s => s.length > 5); + + if (parts.length <= 1) { + return [{ step: prompt.slice(0, 100), risk: "🟡 MEDIUM" }]; + } + + return parts.map(part => { + const risk = /schema|migrat|database|config|env|deploy/i.test(part) ? "🔴 HIGH" : + /api|route|endpoint/i.test(part) ? "🟡 MEDIUM" : "🟢 LOW"; + return { step: part.charAt(0).toUpperCase() + part.slice(1), risk }; + }); +} diff --git a/src/lib/triage.ts b/src/lib/triage.ts index b153a7f..f21aeb1 100644 --- a/src/lib/triage.ts +++ b/src/lib/triage.ts @@ -73,7 +73,8 @@ function isTrivialCommand(prompt: string): boolean { } function hasFileRefs(prompt: string): boolean { - return FILE_PATH_RE.test(prompt); + // Standard paths (src/auth.ts) or standalone dotfiles (.env, .gitignore) + return FILE_PATH_RE.test(prompt) || /(?:^|[\s,:(])\.[a-zA-Z][\w.-]*/.test(prompt); } function hasLineNumbers(prompt: string): boolean { diff --git a/src/tools/preflight-check.ts b/src/tools/preflight-check.ts index 8c9121a..dcbd6a5 100644 --- a/src/tools/preflight-check.ts +++ b/src/tools/preflight-check.ts @@ -12,31 +12,21 @@ import { getConfig } from "../lib/config.js"; import { searchSemantic } from "../lib/timeline-db.js"; import { basename, join } from "path"; import { loadPatterns, matchPatterns, formatPatternMatches } from "../lib/patterns.js"; +import { + extractFilePaths, + verifyFiles as verifyFilesHelper, + detectAmbiguity, + estimateComplexity, + splitSubtasks, +} from "../lib/preflight-helpers.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -/** Extract file paths from prompt text */ -function extractFilePaths(prompt: string): string[] { - const matches = prompt.match(/[\w\-./\\]+\.\w{1,6}/g) || []; - return [...new Set(matches)]; -} - -/** Verify files exist and return stats */ +/** Verify files against PROJECT_DIR */ function verifyFiles(paths: string[]): string[] { - const lines: string[] = []; - for (const p of paths) { - const abs = resolve(PROJECT_DIR, p); - if (!abs.startsWith(resolve(PROJECT_DIR))) continue; // path traversal guard - if (existsSync(abs)) { - const s = statSync(abs); - lines.push(`✅ \`${p}\` — ${s.size} bytes, modified ${s.mtime.toISOString().slice(0, 16)}`); - } else { - lines.push(`❌ \`${p}\` — not found`); - } - } - return lines; + return verifyFilesHelper(paths, PROJECT_DIR); } /** Get related project paths from config + env */ @@ -127,9 +117,7 @@ function buildScopeSection(prompt: string): string[] { } // Estimate complexity - const hasMultipleFiles = filePaths.length > 3; - const hasMultipleDirs = new Set(filePaths.map(f => f.split("/")[0])).size > 2; - const complexity = hasMultipleFiles && hasMultipleDirs ? "LARGE" : filePaths.length > 1 ? "MEDIUM" : "SMALL"; + const complexity = estimateComplexity(filePaths); sections.push(`### Scope: ${complexity}`); return sections; @@ -137,24 +125,8 @@ function buildScopeSection(prompt: string): string[] { /** Build sequence section for multi-step */ function buildSequenceSection(prompt: string): string[] { - // Split prompt into sub-tasks - const subtasks: string[] = []; - - // Split on "and", "then", numbered lists, bullet points - const parts = prompt - .split(/\b(?:then|after that|next|finally)\b|(?:,\s*and\s+)|(?:\band\b(?=\s+(?:update|add|remove|create|fix|change|refactor|implement|deploy)))/i) - .map(s => s.trim()) - .filter(s => s.length > 5); - - if (parts.length > 1) { - for (let i = 0; i < parts.length; i++) { - const risk = /schema|migrat|database|config|env|deploy/i.test(parts[i]) ? "🔴 HIGH" : - /api|route|endpoint/i.test(parts[i]) ? "🟡 MEDIUM" : "🟢 LOW"; - subtasks.push(`${i + 1}. ${parts[i].charAt(0).toUpperCase() + parts[i].slice(1)} — Risk: ${risk}`); - } - } else { - subtasks.push(`1. ${prompt.slice(0, 100)} — Risk: 🟡 MEDIUM`); - } + const tasks = splitSubtasks(prompt); + const subtasks = tasks.map((t, i) => `${i + 1}. ${t.step} — Risk: ${t.risk}`); return [ `### Execution Plan`, diff --git a/tests/lib/embeddings.test.ts b/tests/lib/embeddings.test.ts index 68b0ccf..679f4ec 100644 --- a/tests/lib/embeddings.test.ts +++ b/tests/lib/embeddings.test.ts @@ -63,4 +63,27 @@ describe("createEmbeddingProvider", () => { }); expect(provider.dimensions).toBe(1536); }); + + it("returns ollama provider with default 768 dimensions", () => { + const provider = createEmbeddingProvider({ provider: "ollama" }); + expect(provider.dimensions).toBe(768); + }); + + it("returns ollama provider with custom dimensions", () => { + const provider = createEmbeddingProvider({ + provider: "ollama", + ollamaDimensions: 1024, + }); + expect(provider.dimensions).toBe(1024); + }); + + it("accepts custom ollama base URL and model", () => { + const provider = createEmbeddingProvider({ + provider: "ollama", + ollamaBaseUrl: "http://my-server:11434", + ollamaModel: "mxbai-embed-large", + ollamaDimensions: 1024, + }); + expect(provider.dimensions).toBe(1024); + }); }); diff --git a/tests/lib/preflight-helpers.test.ts b/tests/lib/preflight-helpers.test.ts new file mode 100644 index 0000000..612ed40 --- /dev/null +++ b/tests/lib/preflight-helpers.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { + extractFilePaths, + detectAmbiguity, + estimateComplexity, + splitSubtasks, +} from "../../src/lib/preflight-helpers.js"; + +describe("extractFilePaths", () => { + it("extracts simple file paths", () => { + expect(extractFilePaths("fix src/auth/jwt.ts")).toContain("src/auth/jwt.ts"); + }); + + it("extracts multiple file paths", () => { + const paths = extractFilePaths("update src/index.ts and lib/utils.js"); + expect(paths).toContain("src/index.ts"); + expect(paths).toContain("lib/utils.js"); + }); + + it("deduplicates paths", () => { + const paths = extractFilePaths("fix src/a.ts then check src/a.ts"); + expect(paths.filter(p => p === "src/a.ts")).toHaveLength(1); + }); + + it("returns empty for no file paths", () => { + expect(extractFilePaths("fix the auth bug")).toEqual([]); + }); + + it("matches bare dotfiles like .env", () => { + expect(extractFilePaths("update .env")).toContain(".env"); + }); + + it("matches dotfiles with extensions like .env.local", () => { + expect(extractFilePaths("check .env.local")).toContain(".env.local"); + }); + + it("matches .gitignore", () => { + expect(extractFilePaths("update .gitignore")).toContain(".gitignore"); + }); + + it("matches dotfiles with directory prefix", () => { + expect(extractFilePaths("update config/.env")).toContain("config/.env"); + }); + + it("does not match lone dots or numbers", () => { + const paths = extractFilePaths("version 2.0 is ready"); + expect(paths).not.toContain(".0"); + }); +}); + +describe("detectAmbiguity", () => { + it("detects vague pronouns", () => { + const issues = detectAmbiguity("fix it"); + expect(issues.some(i => i.includes("vague pronouns"))).toBe(true); + }); + + it("detects vague verbs without file targets", () => { + const issues = detectAmbiguity("fix the auth bug"); + expect(issues.some(i => i.includes("Vague verb"))).toBe(true); + }); + + it("does not flag vague verbs when file paths present", () => { + const issues = detectAmbiguity("fix the bug in src/auth/jwt.ts"); + expect(issues.some(i => i.includes("Vague verb"))).toBe(false); + }); + + it("flags very short prompts", () => { + const issues = detectAmbiguity("fix bug"); + expect(issues.some(i => i.includes("Very short"))).toBe(true); + }); + + it("returns empty for clear prompts with file refs", () => { + const issues = detectAmbiguity("Add error handling to the validateToken function in src/auth/jwt.ts for expired tokens"); + expect(issues).toEqual([]); + }); +}); + +describe("estimateComplexity", () => { + it("returns SMALL for 0-1 files", () => { + expect(estimateComplexity([])).toBe("SMALL"); + expect(estimateComplexity(["src/a.ts"])).toBe("SMALL"); + }); + + it("returns MEDIUM for 2-3 files", () => { + expect(estimateComplexity(["src/a.ts", "src/b.ts"])).toBe("MEDIUM"); + }); + + it("returns LARGE for many files across dirs", () => { + expect(estimateComplexity([ + "src/a.ts", "lib/b.ts", "tests/c.ts", "config/d.json", + ])).toBe("LARGE"); + }); + + it("returns MEDIUM for many files in same dir", () => { + expect(estimateComplexity([ + "src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts", + ])).toBe("MEDIUM"); + }); +}); + +describe("splitSubtasks", () => { + it("returns single task for simple prompt", () => { + const tasks = splitSubtasks("fix the login bug"); + expect(tasks).toHaveLength(1); + expect(tasks[0].risk).toBe("🟡 MEDIUM"); + }); + + it("splits on 'then'", () => { + const tasks = splitSubtasks("update the schema then fix the API endpoint"); + expect(tasks.length).toBeGreaterThan(1); + }); + + it("assigns HIGH risk to database/migration tasks", () => { + const tasks = splitSubtasks("run the database migration then update the API endpoint"); + const dbTask = tasks.find(t => /database/i.test(t.step)); + expect(dbTask?.risk).toBe("🔴 HIGH"); + }); + + it("assigns MEDIUM risk to API tasks", () => { + const tasks = splitSubtasks("update the tests then fix the API endpoint"); + const apiTask = tasks.find(t => /API endpoint/i.test(t.step)); + expect(apiTask?.risk).toBe("🟡 MEDIUM"); + }); + + it("assigns LOW risk to generic tasks", () => { + const tasks = splitSubtasks("update the tests then fix the typo in README"); + const readmeTask = tasks.find(t => /README/i.test(t.step)); + expect(readmeTask?.risk).toBe("🟢 LOW"); + }); +}); diff --git a/tests/lib/triage.test.ts b/tests/lib/triage.test.ts index 3f34c1e..ddd2cbb 100644 --- a/tests/lib/triage.test.ts +++ b/tests/lib/triage.test.ts @@ -127,4 +127,9 @@ describe("triagePrompt", () => { const result = triagePrompt("fix the bug"); expect(result.level).toBe("ambiguous"); }); + + it("recognizes dotfiles as file references (not ambiguous)", () => { + const result = triagePrompt("update the DATABASE_URL in .env to point to staging"); + expect(result.level).not.toBe("ambiguous"); + }); });