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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20]
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"preflight-dev": "./bin/cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"scripts": {
"build": "tsc",
Expand Down
4 changes: 2 additions & 2 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function loadConfig(): PreflightConfig {
if (existsSync(configPath)) {
try {
const configYaml = readFileSync(configPath, "utf-8");
const configData = yamlLoad(configYaml) as any;
const configData = yamlLoad(configYaml) as Partial<PreflightConfig> | undefined;

if (configData) {
// Merge config data with defaults
Expand All @@ -96,7 +96,7 @@ function loadConfig(): PreflightConfig {
if (existsSync(triagePath)) {
try {
const triageYaml = readFileSync(triagePath, "utf-8");
const triageData = yamlLoad(triageYaml) as any;
const triageData = yamlLoad(triageYaml) as Partial<PreflightConfig["triage"]> | undefined;

if (triageData) {
if (triageData.rules) config.triage.rules = { ...config.triage.rules, ...triageData.rules };
Expand Down
8 changes: 6 additions & 2 deletions src/lib/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,13 @@ function extractOpenApiContracts(content: string, filePath: string, projectDir:
const now = new Date().toISOString();

try {
const spec = filePath.endsWith(".json") ? JSON.parse(content) : yamlLoad(content) as any;
interface OpenApiSpec {
paths?: Record<string, Record<string, { summary?: string; parameters?: unknown; requestBody?: unknown }>>;
components?: { schemas?: Record<string, unknown> };
}
const spec: OpenApiSpec = filePath.endsWith(".json") ? JSON.parse(content) : yamlLoad(content) as OpenApiSpec;
if (spec?.paths) {
for (const [path, methods] of Object.entries(spec.paths as Record<string, any>)) {
for (const [path, methods] of Object.entries(spec.paths)) {
for (const method of Object.keys(methods)) {
if (["get", "post", "put", "delete", "patch"].includes(method)) {
const op = methods[method];
Expand Down
15 changes: 10 additions & 5 deletions src/lib/embeddings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,16 @@ export function preprocessText(text: string): string {

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

let extractor: any = null;
/** Xenova feature-extraction pipeline instance */
interface FeatureExtractor {
(text: string, options: { pooling: string; normalize: boolean }): Promise<{ data: Float32Array }>;
}

let extractor: FeatureExtractor | null = null;

async function getExtractor(): Promise<any> {
async function getExtractor(): Promise<FeatureExtractor> {
if (!extractor) {
extractor = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2");
extractor = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2") as unknown as FeatureExtractor;
}
return extractor;
}
Expand Down Expand Up @@ -90,9 +95,9 @@ class OpenAIEmbeddingProvider implements EmbeddingProvider {
throw new Error(`OpenAI embeddings API error ${resp.status}: ${err}`);
}

const data = await resp.json();
const data = await resp.json() as { data: Array<{ index: number; embedding: number[] }> };
// Sort by index to preserve order
const sorted = data.data.sort((a: any, b: any) => a.index - b.index);
const sorted = data.data.sort((a, b) => a.index - b.index);
for (const item of sorted) {
results.push(item.embedding);
}
Expand Down
5 changes: 3 additions & 2 deletions src/lib/git-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ export function extractGitHistory(
maxBuffer: 50 * 1024 * 1024,
stdio: ["pipe", "pipe", "pipe"],
});
} catch (err: any) {
} catch (err: unknown) {
// No commits or other git error
if (err.stdout) output = err.stdout;
const execErr = err as { stdout?: string };
if (execErr.stdout) output = execErr.stdout;
else return [];
}

Expand Down
41 changes: 31 additions & 10 deletions src/lib/session-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,41 @@ const CORRECTION_PATTERNS = [

// ── Helpers ────────────────────────────────────────────────────────────────

/** A single record from a Claude Code JSONL session file */
interface SessionRecord {
type?: string;
subtype?: string;
timestamp?: string | number;
message?: { content?: unknown };
content?: unknown;
model?: string;
gitBranch?: string;
sessionId?: string;
is_error?: boolean;
tool_use_id?: string;
}

interface ContentBlock {
type: string;
text?: string;
name?: string;
input?: unknown;
}

function extractText(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.filter((b: any) => b.type === "text" && typeof b.text === "string")
.map((b: any) => b.text)
return (content as ContentBlock[])
.filter((b) => b.type === "text" && typeof b.text === "string")
.map((b) => b.text!)
.join("\n");
}
return "";
}

function extractToolUseBlocks(content: unknown): any[] {
function extractToolUseBlocks(content: unknown): ContentBlock[] {
if (!Array.isArray(content)) return [];
return content.filter((b: any) => b.type === "tool_use");
return (content as ContentBlock[]).filter((b) => b.type === "tool_use");
}

function normalizeTimestamp(ts: unknown, fallback: string): string {
Expand Down Expand Up @@ -185,9 +206,9 @@ export async function parseSessionAsync(
for await (const line of rl) {
lineNum++;
if (!line.trim()) continue;
let obj: any;
let obj: SessionRecord;
try {
obj = JSON.parse(line);
obj = JSON.parse(line) as SessionRecord;
} catch {
process.stderr.write(`[session-parser] malformed line ${lineNum} in ${filePath}\n`);
continue;
Expand Down Expand Up @@ -220,9 +241,9 @@ function parseLinesSync(
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
let obj: any;
let obj: SessionRecord;
try {
obj = JSON.parse(line);
obj = JSON.parse(line) as SessionRecord;
} catch {
process.stderr.write(`[session-parser] malformed line ${i + 1} in ${filePath}\n`);
continue;
Expand All @@ -242,7 +263,7 @@ function parseLinesSync(
}

function processRecord(
obj: any,
obj: SessionRecord,
filePath: string,
project: string,
projectName: string,
Expand Down
13 changes: 8 additions & 5 deletions src/lib/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, sta
import { join } from "path";
import { PROJECT_DIR } from "./files.js";

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic JSON state needs flexible value types
export type JsonRecord = Record<string, any>;

export const STATE_DIR = join(PROJECT_DIR, ".claude", "preflight-state");

/** Max log file size in bytes (5 MB). Triggers rotation. */
Expand All @@ -18,7 +21,7 @@ function ensureStateDir(): void {
* Load a JSON state file by name (without extension).
* Returns empty object if missing or corrupt.
*/
export function loadState(name: string): Record<string, any> {
export function loadState(name: string): JsonRecord {
const p = join(STATE_DIR, `${name}.json`);
if (!existsSync(p)) return {};
try {
Expand All @@ -31,15 +34,15 @@ export function loadState(name: string): Record<string, any> {
/**
* Save a JSON state file by name (without extension).
*/
export function saveState(name: string, data: Record<string, any>): void {
export function saveState(name: string, data: JsonRecord): void {
ensureStateDir();
writeFileSync(join(STATE_DIR, `${name}.json`), JSON.stringify(data, null, 2));
}

/**
* Append a JSONL entry to a log file. Rotates if file exceeds MAX_LOG_SIZE.
*/
export function appendLog(filename: string, entry: Record<string, any>): void {
export function appendLog(filename: string, entry: JsonRecord): void {
ensureStateDir();
const logFile = join(STATE_DIR, filename);

Expand All @@ -62,15 +65,15 @@ export function appendLog(filename: string, entry: Record<string, any>): void {
* Read a JSONL log file. Pass `lastN` to only return the last N entries
* (still reads the file, but avoids allocating all parsed objects).
*/
export function readLog(filename: string, lastN?: number): Record<string, any>[] {
export function readLog(filename: string, lastN?: number): JsonRecord[] {
const logFile = join(STATE_DIR, filename);
if (!existsSync(logFile)) return [];
try {
const raw = readFileSync(logFile, "utf-8").trim();
if (!raw) return [];
const lines = raw.split("\n");
const subset = lastN != null && lastN > 0 ? lines.slice(-lastN) : lines;
const results: Record<string, any>[] = [];
const results: JsonRecord[] = [];
for (const line of subset) {
try { results.push(JSON.parse(line)); } catch { /* skip corrupt line */ }
}
Expand Down
6 changes: 3 additions & 3 deletions src/tools/clarify-intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ function getTestFailures(): string {
const fp = join(PROJECT_DIR, p);
if (existsSync(fp)) {
try {
const data = JSON.parse(readFileSync(fp, "utf-8"));
const data = JSON.parse(readFileSync(fp, "utf-8")) as { testResults?: Array<{ status: string; name: string }> };
const failed = data.testResults
?.filter((t: any) => t.status === "failed")
?.map((t: any) => t.name) || [];
?.filter((t) => t.status === "failed")
?.map((t) => t.name) || [];
return failed.length ? failed.join("\n") : "all passing";
} catch { continue; }
}
Expand Down
25 changes: 16 additions & 9 deletions src/tools/estimate-cost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,29 @@ function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}

interface ContentBlock {
type?: string;
text?: string;
name?: string;
input?: unknown;
}

function extractText(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.filter((b: any) => typeof b.text === "string")
.map((b: any) => b.text)
return (content as ContentBlock[])
.filter((b) => typeof b.text === "string")
.map((b) => b.text!)
.join("\n");
}
return "";
}

function extractToolNames(content: unknown): string[] {
if (!Array.isArray(content)) return [];
return content
.filter((b: any) => b.type === "tool_use" && b.name)
.map((b: any) => b.name as string);
return (content as ContentBlock[])
.filter((b) => b.type === "tool_use" && b.name)
.map((b) => b.name!);
}

function formatTokens(n: number): string {
Expand Down Expand Up @@ -106,7 +113,7 @@ function analyzeSessionFile(filePath: string): SessionAnalysis {
let lastAssistantTokens = 0;

for (const line of lines) {
let obj: any;
let obj: { type?: string; timestamp?: string | number; message?: { content?: unknown }; content?: unknown; model?: string; is_error?: boolean; tool_use_id?: string };
try {
obj = JSON.parse(line);
} catch {
Expand Down Expand Up @@ -148,8 +155,8 @@ function analyzeSessionFile(filePath: string): SessionAnalysis {
if (PREFLIGHT_TOOLS.has(name)) {
result.preflightCalls++;
// Estimate tool call tokens (name + args)
const toolBlocks = (msgContent as any[]).filter(
(b: any) => b.type === "tool_use" && b.name === name,
const toolBlocks = (msgContent as ContentBlock[]).filter(
(b) => b.type === "tool_use" && b.name === name,
);
for (const tb of toolBlocks) {
result.preflightTokens += estimateTokens(
Expand Down
14 changes: 11 additions & 3 deletions src/tools/search-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { searchSemantic, listIndexedProjects } from "../lib/timeline-db.js";
import { getRelatedProjects } from "../lib/config.js";
import type { SearchScope } from "../types.js";
import type { TimelineEvent } from "../lib/session-parser.js";

/** Search result with optional distance score from vector search */
interface SearchResult extends TimelineEvent {
_distance?: number;
summary?: string;
commit_hash?: string;
}

const RELATIVE_DATE_RE = /^(\d+)(days?|weeks?|months?|years?)$/;

Expand Down Expand Up @@ -101,7 +109,7 @@ export function registerSearchHistory(server: McpServer) {
// Post-filter by author (stored in metadata JSON)
if (params.author) {
const authorLower = params.author.toLowerCase();
results = results.filter((r: any) => {
results = results.filter((r: SearchResult) => {
try {
const meta = JSON.parse(r.metadata || "{}");
return (meta.author || "").toLowerCase().includes(authorLower);
Expand All @@ -113,14 +121,14 @@ export function registerSearchHistory(server: McpServer) {
return { content: [{ type: "text", text: `## Search Results for "${params.query}"\n_No results found._` }] };
}

const projects = new Set(results.map((r: any) => r.project || "unknown"));
const projects = new Set(results.map((r: SearchResult) => r.project || "unknown"));
const lines: string[] = [
`## Search Results for "${params.query}"`,
`_${results.length} result${results.length !== 1 ? "s" : ""} across ${projects.size} project${projects.size !== 1 ? "s" : ""}_`,
"",
];

results.forEach((event: any, i: number) => {
results.forEach((event: SearchResult, i: number) => {
const badge = TYPE_BADGES[event.type] || event.type;
const ts = event.timestamp ? new Date(event.timestamp).toISOString().replace("T", " ").slice(0, 16) : "unknown";
const proj = event.project || "unknown";
Expand Down
14 changes: 11 additions & 3 deletions src/tools/timeline-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { getTimeline, listIndexedProjects } from "../lib/timeline-db.js";
import { getRelatedProjects } from "../lib/config.js";
import type { SearchScope } from "../types.js";
import type { TimelineEvent } from "../lib/session-parser.js";

/** Extended timeline event with optional fields populated by the DB layer */
interface TimelineRecord extends TimelineEvent {
summary?: string;
commit_hash?: string;
tool_name?: string;
}

const RELATIVE_DATE_RE = /^(\d+)(days?|weeks?|months?|years?)$/;

Expand Down Expand Up @@ -102,7 +110,7 @@ export function registerTimeline(server: McpServer) {
// Post-filter by author
if (params.author) {
const authorLower = params.author.toLowerCase();
events = events.filter((e: any) => {
events = events.filter((e: TimelineRecord) => {
if (e.type !== "commit") return true; // only filter commits
try {
const meta = JSON.parse(e.metadata || "{}");
Expand All @@ -116,7 +124,7 @@ export function registerTimeline(server: McpServer) {
}

// Group by day
const days = new Map<string, any[]>();
const days = new Map<string, TimelineRecord[]>();
for (const event of events) {
const day = event.timestamp ? new Date(event.timestamp).toISOString().slice(0, 10) : "unknown";
if (!days.has(day)) days.set(day, []);
Expand All @@ -141,7 +149,7 @@ export function registerTimeline(server: McpServer) {
lines.push(`### ${day}`);
const dayEvents = days.get(day)!;
// Sort by timestamp within day
dayEvents.sort((a: any, b: any) => {
dayEvents.sort((a: TimelineRecord, b: TimelineRecord) => {
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return ta - tb;
Expand Down
Loading