From 34c3b54b21b30eb3e5e6db9cddf5c10c232a4c9a Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Tue, 3 Mar 2026 16:44:43 -0700 Subject: [PATCH 1/3] feat: add Ollama embedding provider (#6) Adds Ollama as a local embedding provider option. Uses the /api/embed endpoint with batch support. Defaults to nomic-embed-text model. Configurable base URL, model name, and dimensions for flexibility with different Ollama models. Closes #6 --- README.md | 9 ++++-- src/lib/embeddings.ts | 61 +++++++++++++++++++++++++++++++++++- tests/lib/embeddings.test.ts | 23 ++++++++++++++ 3 files changed, 90 insertions(+), 3 deletions(-) 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/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); + }); }); From 2c7d9fb991244c7ab7ceb326f15ffb40189ff1ca Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Tue, 3 Mar 2026 17:15:40 -0700 Subject: [PATCH 2/3] refactor: extract preflight_check helpers into testable module - Extract extractFilePaths, detectAmbiguity, estimateComplexity, splitSubtasks into src/lib/preflight-helpers.ts - Update preflight-check.ts to import from shared module - Add 19 tests covering all extracted helpers - All 66 tests pass, build clean --- src/lib/preflight-helpers.ts | 71 +++++++++++++++++ src/tools/preflight-check.ts | 52 +++--------- tests/lib/preflight-helpers.test.ts | 118 ++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 40 deletions(-) create mode 100644 src/lib/preflight-helpers.ts create mode 100644 tests/lib/preflight-helpers.test.ts diff --git a/src/lib/preflight-helpers.ts b/src/lib/preflight-helpers.ts new file mode 100644 index 0000000..e68fdc7 --- /dev/null +++ b/src/lib/preflight-helpers.ts @@ -0,0 +1,71 @@ +/** + * 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[] { + const matches = prompt.match(/[\w\-./\\]+\.\w{1,6}/g) || []; + return [...new Set(matches)]; +} + +/** 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/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/preflight-helpers.test.ts b/tests/lib/preflight-helpers.test.ts new file mode 100644 index 0000000..b736aa6 --- /dev/null +++ b/tests/lib/preflight-helpers.test.ts @@ -0,0 +1,118 @@ +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("does not match bare dotfiles (regex limitation)", () => { + // The regex requires word chars before the dot + expect(extractFilePaths("update .env")).toEqual([]); + }); + + it("matches dotfiles with directory prefix", () => { + expect(extractFilePaths("update config/.env")).toContain("config/.env"); + }); +}); + +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"); + }); +}); From 16a214418e00ef29376c0b079c69fcf362486098 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Tue, 3 Mar 2026 18:14:54 -0700 Subject: [PATCH 3/3] fix: recognize dotfiles (.env, .gitignore) in file path extraction and triage Previously, standalone dotfiles like .env were not detected by extractFilePaths() or the triage hasFileRefs() check, causing prompts like 'update .env' to be incorrectly classified as ambiguous. - Updated extractFilePaths regex to match standalone dotfiles - Updated triage hasFileRefs to recognize dotfile patterns - Added 4 new tests for dotfile extraction - Added triage test for dotfile recognition - 70 tests passing --- src/lib/preflight-helpers.ts | 7 +++++-- src/lib/triage.ts | 3 ++- tests/lib/preflight-helpers.test.ts | 18 +++++++++++++++--- tests/lib/triage.test.ts | 5 +++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/lib/preflight-helpers.ts b/src/lib/preflight-helpers.ts index e68fdc7..43080f9 100644 --- a/src/lib/preflight-helpers.ts +++ b/src/lib/preflight-helpers.ts @@ -9,8 +9,11 @@ import { resolve } from "path"; /** Extract file paths from prompt text */ export function extractFilePaths(prompt: string): string[] { - const matches = prompt.match(/[\w\-./\\]+\.\w{1,6}/g) || []; - return [...new Set(matches)]; + // 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. */ 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/tests/lib/preflight-helpers.test.ts b/tests/lib/preflight-helpers.test.ts index b736aa6..612ed40 100644 --- a/tests/lib/preflight-helpers.test.ts +++ b/tests/lib/preflight-helpers.test.ts @@ -26,14 +26,26 @@ describe("extractFilePaths", () => { expect(extractFilePaths("fix the auth bug")).toEqual([]); }); - it("does not match bare dotfiles (regex limitation)", () => { - // The regex requires word chars before the dot - expect(extractFilePaths("update .env")).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", () => { 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"); + }); });