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
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
255 changes: 255 additions & 0 deletions tests/lib/session-parser.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading