Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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.
Expand Down
61 changes: 60 additions & 1 deletion src/lib/embeddings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@

// --- Local Provider (Xenova/transformers) ---

let extractor: any = null;

Check warning on line 29 in src/lib/embeddings.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

Unexpected any. Specify a different type

Check warning on line 29 in src/lib/embeddings.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22)

Unexpected any. Specify a different type

async function getExtractor(): Promise<any> {

Check warning on line 31 in src/lib/embeddings.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

Unexpected any. Specify a different type

Check warning on line 31 in src/lib/embeddings.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22)

Unexpected any. Specify a different type
if (!extractor) {
extractor = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2");
}
Expand Down Expand Up @@ -92,7 +92,7 @@

const data = await resp.json();
// Sort by index to preserve order
const sorted = data.data.sort((a: any, b: any) => a.index - b.index);

Check warning on line 95 in src/lib/embeddings.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

Unexpected any. Specify a different type

Check warning on line 95 in src/lib/embeddings.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

Unexpected any. Specify a different type

Check warning on line 95 in src/lib/embeddings.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22)

Unexpected any. Specify a different type

Check warning on line 95 in src/lib/embeddings.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22)

Unexpected any. Specify a different type
for (const item of sorted) {
results.push(item.embedding);
}
Expand All @@ -102,17 +102,76 @@
}
}

// --- 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<number[]> {
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<number[][]> {
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 {
if (config.provider === "openai") {
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();
}
71 changes: 71 additions & 0 deletions src/lib/preflight-helpers.ts
Original file line number Diff line number Diff line change
@@ -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 };
});
}
52 changes: 12 additions & 40 deletions src/tools/preflight-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -127,34 +117,16 @@ 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;
}

/** 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`,
Expand Down
23 changes: 23 additions & 0 deletions tests/lib/embeddings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
118 changes: 118 additions & 0 deletions tests/lib/preflight-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading