Skip to content
Closed
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
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,11 +268,11 @@ Run `onboard_project` to index a project's history. Here's what happens:
2. **Parses events** — extracts the 8 event types from each session file (streams files >10MB)
3. **Extracts contracts** — scans source for types, interfaces, enums, routes, Prisma models, OpenAPI schemas
4. **Loads manual contracts** — merges any `.preflight/contracts/*.yml` definitions (manual wins on name conflicts)
5. **Generates embeddings** — local [Xenova/all-MiniLM-L6-v2](https://huggingface.co/Xenova/all-MiniLM-L6-v2) by default (~90MB model download on first run, ~50 events/sec) or OpenAI if `OPENAI_API_KEY` is set (~200 events/sec)
5. **Generates embeddings** — local [Xenova/all-MiniLM-L6-v2](https://huggingface.co/Xenova/all-MiniLM-L6-v2) by default (~90MB model download on first run, ~50 events/sec), OpenAI if `OPENAI_API_KEY` is set (~200 events/sec), or [Ollama](https://ollama.com) for local GPU-accelerated embeddings with no external API
6. **Stores in LanceDB** — per-project database at `~/.preflight/projects/<sha256-12>/timeline.lance/`
7. **Updates registry** — records the project in `~/.preflight/projects/index.json`

No data leaves your machine unless you opt into OpenAI embeddings.
No data leaves your machine unless you opt into OpenAI embeddings. Ollama embeddings also stay fully local.

After onboarding, you get:
- 🔎 **Semantic search** — "How did I set up auth middleware last month?" actually works
Expand Down 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 — Ollama API URL (default shown)
ollama_model: nomic-embed-text # type: string — Ollama embedding model (default shown)
ollama_dimensions: 768 # type: number — vector dimensions for your model (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 API base URL | `http://localhost:11434` |
| `OLLAMA_EMBED_MODEL` | Ollama embedding model name | `nomic-embed-text` |
| `PROMPT_DISCIPLINE_PROFILE` | `minimal`, `standard`, or `full` | `standard` |

Environment variables are **fallbacks** — `.preflight/` config takes precedence when present.
Expand Down
15 changes: 13 additions & 2 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { PROJECT_DIR } from "./files.js";

export type Profile = "minimal" | "standard" | "full";
export type EmbeddingProvider = "local" | "openai";
export type EmbeddingProvider = "local" | "openai" | "ollama";
export type TriageStrictness = "relaxed" | "standard" | "strict";

export interface RelatedProject {
Expand All @@ -30,6 +30,9 @@
embeddings: {
provider: EmbeddingProvider;
openai_api_key?: string;
ollama_base_url?: string;
ollama_model?: string;
ollama_dimensions?: number;
};
triage: {
rules: {
Expand Down Expand Up @@ -78,7 +81,7 @@
if (existsSync(configPath)) {
try {
const configYaml = readFileSync(configPath, "utf-8");
const configData = yamlLoad(configYaml) as any;

Check warning on line 84 in src/lib/config.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22)

Unexpected any. Specify a different type

Check warning on line 84 in src/lib/config.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

Unexpected any. Specify a different type

if (configData) {
// Merge config data with defaults
Expand All @@ -96,7 +99,7 @@
if (existsSync(triagePath)) {
try {
const triageYaml = readFileSync(triagePath, "utf-8");
const triageData = yamlLoad(triageYaml) as any;

Check warning on line 102 in src/lib/config.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22)

Unexpected any. Specify a different type

Check warning on line 102 in src/lib/config.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

Unexpected any. Specify a different type

if (triageData) {
if (triageData.rules) config.triage.rules = { ...config.triage.rules, ...triageData.rules };
Expand Down Expand Up @@ -125,14 +128,22 @@

// Embedding provider
const envProvider = process.env.EMBEDDING_PROVIDER?.toLowerCase();
if (envProvider === "local" || envProvider === "openai") {
if (envProvider === "local" || envProvider === "openai" || envProvider === "ollama") {
config.embeddings.provider = envProvider;
}

// OpenAI API key
if (process.env.OPENAI_API_KEY) {
config.embeddings.openai_api_key = process.env.OPENAI_API_KEY;
}

// Ollama settings
if (process.env.OLLAMA_BASE_URL) {
config.embeddings.ollama_base_url = process.env.OLLAMA_BASE_URL;
}
if (process.env.OLLAMA_EMBED_MODEL) {
config.embeddings.ollama_model = process.env.OLLAMA_EMBED_MODEL;
}
}

return config;
Expand Down
69 changes: 68 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,84 @@
}
}

// --- 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";
this.model = options?.model ?? "nomic-embed-text";
// nomic-embed-text produces 768-dim vectors; allow override for other models
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 API error ${resp.status}: ${err}`);
}

const data = await resp.json();
// /api/embed returns { embeddings: number[][] }
if (!data.embeddings?.[0]) {
throw new Error("Ollama returned no embeddings");
}
return data.embeddings[0];
}

async embedBatch(texts: string[]): Promise<number[][]> {
// Ollama /api/embed supports multiple inputs natively
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 API error ${resp.status}: ${err}`);
}

const data = await resp.json();
if (!data.embeddings || data.embeddings.length !== texts.length) {
throw new Error(`Ollama returned ${data.embeddings?.length ?? 0} embeddings, expected ${texts.length}`);
}
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();
}
8 changes: 7 additions & 1 deletion src/lib/timeline-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,12 @@ export interface ProjectInfo {
}

export interface TimelineConfig {
embedding_provider: "local" | "openai";
embedding_provider: "local" | "openai" | "ollama";
embedding_model: string;
openai_api_key?: string;
ollama_base_url?: string;
ollama_model?: string;
ollama_dimensions?: number;
indexed_projects: Record<string, {
last_session_index: string;
last_git_index: string;
Expand Down Expand Up @@ -186,6 +189,9 @@ async function getEmbedder(): Promise<EmbeddingProvider> {
_embedder = createEmbeddingProvider({
provider: config.embedding_provider,
apiKey: config.openai_api_key,
ollamaBaseUrl: config.ollama_base_url,
ollamaModel: config.ollama_model,
ollamaDimensions: config.ollama_dimensions,
});
}
return _embedder;
Expand Down
8 changes: 6 additions & 2 deletions src/tools/onboard-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,17 @@ export function registerOnboardProject(server: McpServer) {
"Index a project's Claude Code sessions and git history into the timeline database for semantic search and chronological viewing.",
{
project_dir: z.string().describe("Absolute path to the project directory"),
embedding_provider: z.enum(["local", "openai"]).default("local"),
embedding_provider: z.enum(["local", "openai", "ollama"]).default("local"),
openai_api_key: z.string().optional(),
ollama_base_url: z.string().optional().describe("Ollama API base URL (default: http://localhost:11434)"),
ollama_model: z.string().optional().describe("Ollama embedding model (default: nomic-embed-text)"),
git_depth: z.enum(["all", "6months", "1year", "3months"]).default("all"),
git_since: z.string().optional().describe("Override git_depth with exact start date (ISO: '2025-08-01')"),
git_authors: z.array(z.string()).optional().describe("Filter git commits to these authors. If omitted, auto-detects the primary author (most commits)."),
reindex: z.boolean().default(false).describe("If true, drop existing data and rebuild from scratch"),
},
async (params) => {
const { project_dir, embedding_provider, openai_api_key, git_depth, git_since, git_authors, reindex } = params;
const { project_dir, embedding_provider, openai_api_key, ollama_base_url, ollama_model, git_depth, git_since, git_authors, reindex } = params;

// 1. Validate project_dir
if (!fs.existsSync(project_dir)) {
Expand Down Expand Up @@ -174,6 +176,8 @@ export function registerOnboardProject(server: McpServer) {
const embedder = createEmbeddingProvider({
provider: embedding_provider,
apiKey: openai_api_key,
ollamaBaseUrl: ollama_base_url,
ollamaModel: ollama_model,
});

const BATCH_SIZE = 50;
Expand Down
74 changes: 28 additions & 46 deletions tests/lib/embeddings.test.ts
Original file line number Diff line number Diff line change
@@ -1,66 +1,48 @@
import { describe, it, expect } from "vitest";
import { preprocessText, createEmbeddingProvider } from "../../src/lib/embeddings.js";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createEmbeddingProvider, preprocessText } from "../../src/lib/embeddings.js";

describe("preprocessText", () => {
it("strips code blocks", () => {
const result = preprocessText("before ```const x = 1;``` after");
expect(result).not.toContain("const x");
expect(result).toContain("before");
expect(result).toContain("after");
});

it("strips inline code", () => {
const result = preprocessText("use `foo()` here");
expect(result).not.toContain("foo()");
expect(result).toContain("use");
expect(result).toContain("here");
});

it("removes markdown characters", () => {
const result = preprocessText("# Heading **bold** _italic_");
it("strips markdown formatting", () => {
const result = preprocessText("# Hello **world** `code` [link](http://x.com)");
expect(result).not.toContain("#");
expect(result).not.toContain("**");
expect(result).not.toContain("`");
expect(result).toContain("link");
});

it("strips markdown link brackets and parens", () => {
const result = preprocessText("[Click here](https://example.com)");
// Brackets and parens are removed by markdown char stripping
expect(result).not.toContain("[");
expect(result).not.toContain("]");
expect(result).not.toContain("(");
expect(result).not.toContain(")");
expect(result).toContain("Click here");
});

it("normalizes whitespace", () => {
const result = preprocessText("hello \n\n world");
expect(result).toBe("hello world");
});

it("truncates to 2048 chars", () => {
const long = "a".repeat(3000);
const result = preprocessText(long);
expect(result.length).toBeLessThanOrEqual(2048);
it("truncates long text to 2048 chars", () => {
const long = "a".repeat(5000);
expect(preprocessText(long).length).toBe(2048);
});
});

describe("createEmbeddingProvider", () => {
it("returns local provider with 384 dimensions", () => {
it("creates local provider by default", () => {
const provider = createEmbeddingProvider({ provider: "local" });
expect(provider.dimensions).toBe(384);
});

it("throws when openai provider has no API key", () => {
expect(() =>
createEmbeddingProvider({ provider: "openai" }),
).toThrow("API key required");
it("creates openai provider with api key", () => {
const provider = createEmbeddingProvider({ provider: "openai", apiKey: "sk-test" });
expect(provider.dimensions).toBe(1536);
});

it("throws when openai provider missing api key", () => {
expect(() => createEmbeddingProvider({ provider: "openai" })).toThrow("OpenAI API key required");
});

it("returns openai provider with 1536 dimensions when key provided", () => {
it("creates ollama provider with defaults", () => {
const provider = createEmbeddingProvider({ provider: "ollama" });
expect(provider.dimensions).toBe(768);
});

it("creates ollama provider with custom config", () => {
const provider = createEmbeddingProvider({
provider: "openai",
apiKey: "sk-test-key",
provider: "ollama",
ollamaBaseUrl: "http://myhost:11434",
ollamaModel: "mxbai-embed-large",
ollamaDimensions: 1024,
});
expect(provider.dimensions).toBe(1536);
expect(provider.dimensions).toBe(1024);
});
});
Loading