From e22719c85a95635768636e837716cf6f6ee6be25 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Wed, 4 Mar 2026 14:19:45 -0700 Subject: [PATCH 1/2] docs: add troubleshooting section to README Covers common issues: LanceDB native modules, first-run model download, missing session data, .preflight/ config not loading, tools not appearing in Claude Code, and permission errors. --- README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/README.md b/README.md index f60fefa..25b3263 100644 --- a/README.md +++ b/README.md @@ -562,6 +562,79 @@ flowchart TB --- +## Troubleshooting + +### LanceDB native module errors + +``` +Error: Failed to load native module +``` + +LanceDB uses native bindings that need to match your platform. Fix: + +```bash +# Remove and reinstall node_modules +rm -rf node_modules package-lock.json +npm install +``` + +If you're on an uncommon platform (musl Linux, older macOS), check [LanceDB compatibility](https://lancedb.github.io/lancedb/). Node 18+ is required. + +### First run is slow / "Downloading model" hangs + +The local embedding provider downloads `Xenova/all-MiniLM-L6-v2` (~90MB) on first use. This is a one-time download — subsequent runs are instant. If it stalls: + +- Check your internet connection +- The model caches to `~/.cache/huggingface/` — ensure you have write access +- Switch to OpenAI embeddings if local doesn't work: set `OPENAI_API_KEY` and configure in `.preflight/config.yml` + +### "No session data found" / timeline search returns nothing + +Timeline tools need indexed session data. Common causes: + +1. **`CLAUDE_PROJECT_DIR` not set** — the server needs this to find your project's session JSONL files. Pass it as an env var when adding the MCP server: + ```bash + claude mcp add preflight -e CLAUDE_PROJECT_DIR=/path/to/project -- npx tsx /path/to/preflight/src/index.ts + ``` +2. **Project not onboarded** — run the `onboard_project` tool first to index existing sessions into LanceDB +3. **No Claude Code sessions exist yet** — timeline search reads from `~/.claude/projects/` which is populated by Claude Code usage + +### `.preflight/` config not loading + +The `.preflight/` directory must be in your **project root** (the directory `CLAUDE_PROJECT_DIR` points to). Verify: + +``` +your-project/ +├── .preflight/ +│ ├── config.yml # Triage thresholds, scoring weights +│ └── triage.yml # Custom skip/always-check keywords +├── src/ +└── ... +``` + +If you renamed or moved your project, update `CLAUDE_PROJECT_DIR` to match. + +### Tools not appearing in Claude Code + +After adding the MCP server, restart Claude Code completely (not just reload). Verify the server is registered: + +```bash +claude mcp list +``` + +You should see `preflight` in the output. If not, re-add it. Check that `npx tsx` works in your shell — you may need to install tsx globally (`npm i -g tsx`) if npx resolution fails. + +### Permission errors on `~/.preflight/` + +The server stores per-project data in `~/.preflight/projects/`. If you get `EACCES` errors: + +```bash +mkdir -p ~/.preflight +chmod 755 ~/.preflight +``` + +--- + ## Contributing This project is young and there's plenty to do. Check the [issues](https://github.com/TerminalGravity/preflight/issues) — several are tagged `good first issue`. From 314909ca4887e761b27078359f6b746e88249fce Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Wed, 4 Mar 2026 14:44:52 -0700 Subject: [PATCH 2/2] test: add comprehensive tests for session-parser module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 17 new tests covering findSessionFiles, parseSession, parseSessionAsync, and parseAllSessions. Covers prompts, corrections, tool calls, sub-agent spawns, errors, compaction, summary records, malformed JSON, epoch timestamps, and multi-file sorting. Test count: 43 → 60. --- tests/lib/session-parser.test.ts | 255 +++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 tests/lib/session-parser.test.ts diff --git a/tests/lib/session-parser.test.ts b/tests/lib/session-parser.test.ts new file mode 100644 index 0000000..a84c30e --- /dev/null +++ b/tests/lib/session-parser.test.ts @@ -0,0 +1,255 @@ +/** + * Tests for src/lib/session-parser.ts + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { writeFileSync, mkdirSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { randomUUID } from "crypto"; + +// We test the exported functions +import { + findSessionFiles, + parseSession, + parseSessionAsync, + parseAllSessions, +} from "../../src/lib/session-parser.js"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function makeTmpDir(): string { + const dir = join(tmpdir(), `preflight-test-${randomUUID()}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeJsonl(dir: string, name: string, records: any[]): string { + const p = join(dir, name); + writeFileSync(p, records.map((r) => JSON.stringify(r)).join("\n") + "\n"); + return p; +} + +// ── findSessionFiles ─────────────────────────────────────────────────────── + +describe("findSessionFiles", () => { + let dir: string; + afterEach(() => dir && rmSync(dir, { recursive: true, force: true })); + + it("returns empty for non-existent directory", () => { + expect(findSessionFiles("/tmp/does-not-exist-" + randomUUID())).toEqual([]); + }); + + it("finds .jsonl files at top level", () => { + dir = makeTmpDir(); + writeFileSync(join(dir, "abc.jsonl"), "{}"); + writeFileSync(join(dir, "readme.txt"), "hi"); + const files = findSessionFiles(dir); + expect(files).toHaveLength(1); + expect(files[0].sessionId).toBe("abc"); + }); + + it("finds subagent session files", () => { + dir = makeTmpDir(); + const parentId = randomUUID(); + const subDir = join(dir, parentId, "subagents"); + mkdirSync(subDir, { recursive: true }); + writeFileSync(join(subDir, "sub1.jsonl"), "{}"); + const files = findSessionFiles(dir); + expect(files.some((f) => f.sessionId === "sub1")).toBe(true); + }); +}); + +// ── parseSession ─────────────────────────────────────────────────────────── + +describe("parseSession", () => { + let dir: string; + afterEach(() => dir && rmSync(dir, { recursive: true, force: true })); + + it("parses user prompts", () => { + dir = makeTmpDir(); + const file = writeJsonl(dir, "s1.jsonl", [ + { type: "user", message: { content: "fix the bug" }, timestamp: "2025-01-01T00:00:00Z" }, + ]); + const events = parseSession(file, "/test", "test"); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("prompt"); + expect(events[0].content).toBe("fix the bug"); + }); + + it("parses assistant responses with text blocks", () => { + dir = makeTmpDir(); + const file = writeJsonl(dir, "s1.jsonl", [ + { + type: "assistant", + message: { content: [{ type: "text", text: "I fixed it" }] }, + timestamp: "2025-01-01T00:01:00Z", + model: "claude-4", + }, + ]); + const events = parseSession(file, "/test", "test"); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("assistant"); + expect(events[0].content).toBe("I fixed it"); + expect(JSON.parse(events[0].metadata).model).toBe("claude-4"); + }); + + it("extracts tool_use blocks as tool_call events", () => { + dir = makeTmpDir(); + const file = writeJsonl(dir, "s1.jsonl", [ + { + type: "assistant", + message: { + content: [ + { type: "tool_use", name: "Read", input: { path: "/foo" } }, + ], + }, + timestamp: "2025-01-01T00:01:00Z", + }, + ]); + const events = parseSession(file, "/test", "test"); + expect(events.some((e) => e.type === "tool_call")).toBe(true); + expect(events.find((e) => e.type === "tool_call")!.content).toContain("Read"); + }); + + it("detects sub_agent_spawn for Task tool", () => { + dir = makeTmpDir(); + const file = writeJsonl(dir, "s1.jsonl", [ + { + type: "assistant", + message: { + content: [{ type: "tool_use", name: "Task", input: { description: "do stuff" } }], + }, + timestamp: "2025-01-01T00:01:00Z", + }, + ]); + const events = parseSession(file, "/test", "test"); + expect(events.some((e) => e.type === "sub_agent_spawn")).toBe(true); + }); + + it("detects corrections after assistant messages", () => { + dir = makeTmpDir(); + const file = writeJsonl(dir, "s1.jsonl", [ + { type: "assistant", message: { content: "here you go" }, timestamp: "2025-01-01T00:00:00Z" }, + { type: "user", message: { content: "no, wrong, I meant the other file" }, timestamp: "2025-01-01T00:01:00Z" }, + ]); + const events = parseSession(file, "/test", "test"); + const correction = events.find((e) => e.type === "correction"); + expect(correction).toBeDefined(); + expect(correction!.content).toContain("wrong"); + }); + + it("detects error tool_results", () => { + dir = makeTmpDir(); + const file = writeJsonl(dir, "s1.jsonl", [ + { type: "tool_result", is_error: true, content: "command failed", timestamp: "2025-01-01T00:00:00Z" }, + ]); + const events = parseSession(file, "/test", "test"); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("error"); + }); + + it("detects compaction events", () => { + dir = makeTmpDir(); + const file = writeJsonl(dir, "s1.jsonl", [ + { type: "system", subtype: "compaction", content: "context compacted", timestamp: "2025-01-01T00:00:00Z" }, + ]); + const events = parseSession(file, "/test", "test"); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("compaction"); + }); + + it("handles summary records for branch and sessionId", () => { + dir = makeTmpDir(); + const file = writeJsonl(dir, "s1.jsonl", [ + { type: "summary", gitBranch: "main", sessionId: "sess-123" }, + { type: "user", message: { content: "hello" }, timestamp: "2025-01-01T00:00:00Z" }, + ]); + const events = parseSession(file, "/test", "test"); + expect(events).toHaveLength(1); + expect(events[0].branch).toBe("main"); + expect(events[0].session_id).toBe("sess-123"); + }); + + it("skips malformed JSON lines gracefully", () => { + dir = makeTmpDir(); + const p = join(dir, "bad.jsonl"); + writeFileSync(p, '{"type":"user","message":{"content":"ok"},"timestamp":"2025-01-01T00:00:00Z"}\nNOT JSON\n'); + const events = parseSession(p, "/test", "test"); + expect(events).toHaveLength(1); + }); + + it("handles epoch timestamps", () => { + dir = makeTmpDir(); + const file = writeJsonl(dir, "s1.jsonl", [ + { type: "user", message: { content: "hi" }, timestamp: 1704067200 }, + ]); + const events = parseSession(file, "/test", "test"); + expect(events[0].timestamp).toBe("2024-01-01T00:00:00.000Z"); + }); + + it("handles epoch ms timestamps", () => { + dir = makeTmpDir(); + const file = writeJsonl(dir, "s1.jsonl", [ + { type: "user", message: { content: "hi" }, timestamp: 1704067200000 }, + ]); + const events = parseSession(file, "/test", "test"); + expect(events[0].timestamp).toBe("2024-01-01T00:00:00.000Z"); + }); +}); + +// ── parseSessionAsync ────────────────────────────────────────────────────── + +describe("parseSessionAsync", () => { + let dir: string; + afterEach(() => dir && rmSync(dir, { recursive: true, force: true })); + + it("produces same results as sync parser", async () => { + dir = makeTmpDir(); + const records = [ + { type: "summary", gitBranch: "feat", sessionId: "s1" }, + { type: "user", message: { content: "do thing" }, timestamp: "2025-06-01T10:00:00Z" }, + { type: "assistant", message: { content: [{ type: "text", text: "done" }] }, timestamp: "2025-06-01T10:01:00Z" }, + ]; + const file = writeJsonl(dir, "s1.jsonl", records); + + const sync = parseSession(file, "/p", "p"); + const async_ = await parseSessionAsync(file, "/p", "p"); + + // Same number of events, same types (async includes summary processing inline) + expect(async_.length).toBe(sync.length); + expect(async_.map((e) => e.type)).toEqual(sync.map((e) => e.type)); + expect(async_.map((e) => e.content)).toEqual(sync.map((e) => e.content)); + }); +}); + +// ── parseAllSessions ─────────────────────────────────────────────────────── + +describe("parseAllSessions", () => { + let dir: string; + afterEach(() => dir && rmSync(dir, { recursive: true, force: true })); + + it("parses all jsonl files and sorts by timestamp", () => { + dir = makeTmpDir(); + writeJsonl(dir, "a.jsonl", [ + { type: "user", message: { content: "second" }, timestamp: "2025-01-01T01:00:00Z" }, + ]); + writeJsonl(dir, "b.jsonl", [ + { type: "user", message: { content: "first" }, timestamp: "2025-01-01T00:00:00Z" }, + ]); + const events = parseAllSessions(dir); + expect(events).toHaveLength(2); + expect(events[0].content).toBe("first"); + expect(events[1].content).toBe("second"); + }); + + it("respects since filter", () => { + dir = makeTmpDir(); + // Create a file, then set its mtime to the past + const p = writeJsonl(dir, "old.jsonl", [ + { type: "user", message: { content: "old" }, timestamp: "2020-01-01T00:00:00Z" }, + ]); + // mtime is "now" so a future since should filter it out + const events = parseAllSessions(dir, { since: new Date("2099-01-01") }); + expect(events).toHaveLength(0); + }); +});