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 (22)

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 (20)

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 (22)

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 (20)

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 (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

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
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();
}
74 changes: 74 additions & 0 deletions src/lib/preflight-helpers.ts
Original file line number Diff line number Diff line change
@@ -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 };
});
}
3 changes: 2 additions & 1 deletion src/lib/triage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
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);
});
});
Loading
Loading