cxs is a local-first CLI for searching Codex session logs. It is designed for progressive retrieval: find the right session first, then read
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: add TypeScript check

cat be75d877 ca4091f2

+122 -91
+4
.gitignore
··· 1 1 node_modules/ 2 2 data/ 3 + .claude/ 4 + .entire/ 5 + .intent/ 6 + research/
+14
bun.lock
··· 8 8 "chalk": "^5.6.2", 9 9 "commander": "^14.0.3", 10 10 }, 11 + "devDependencies": { 12 + "@types/bun": "^1.3.13", 13 + "typescript": "^6.0.3", 14 + }, 11 15 }, 12 16 }, 13 17 "packages": { 18 + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], 19 + 20 + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], 21 + 22 + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], 23 + 14 24 "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], 15 25 16 26 "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], 27 + 28 + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], 29 + 30 + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], 17 31 } 18 32 }
+2 -10
cli.test.ts
··· 53 53 `); 54 54 db.run( 55 55 "INSERT INTO threads (id, rollout_path, cwd, title, updated_at_ms) VALUES (?, ?, ?, ?, ?)", 56 - "aaaa1111-1111-4111-8111-111111111111", 57 - "/tmp/one.jsonl", 58 - "/tmp/picc", 59 - "older", 60 - 100, 56 + ["aaaa1111-1111-4111-8111-111111111111", "/tmp/one.jsonl", "/tmp/picc", "older", 100], 61 57 ); 62 58 db.run( 63 59 "INSERT INTO threads (id, rollout_path, cwd, title, updated_at_ms) VALUES (?, ?, ?, ?, ?)", 64 - "bbbb2222-2222-4222-8222-222222222222", 65 - "/tmp/two.jsonl", 66 - "/tmp/picc", 67 - "newer", 68 - 200, 60 + ["bbbb2222-2222-4222-8222-222222222222", "/tmp/two.jsonl", "/tmp/picc", "newer", 200], 69 61 ); 70 62 db.close(); 71 63
+62 -54
db.ts
··· 1 1 import { existsSync } from "node:fs"; 2 2 import { Database } from "bun:sqlite"; 3 + import type { SQLQueryBindings } from "bun:sqlite"; 3 4 import { tokenizedText } from "./tokenize"; 4 5 import type { 5 6 CwdCount, ··· 12 13 13 14 const CUSTOM_SQLITE = "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib"; 14 15 const BUSY_TIMEOUT_MS = 5000; 16 + type SqlParams = SQLQueryBindings[]; 15 17 16 18 if (existsSync(CUSTOM_SQLITE)) { 17 19 Database.setCustomSQLite(CUSTOM_SQLITE); ··· 138 140 filePath: string, 139 141 ): { rawFileMtime: number; rawFileSize: number; indexVersion: string } | null { 140 142 const row = db 141 - .query(` 143 + .query<{ rawFileMtime: number; rawFileSize: number; indexVersion: string }, [string]>(` 142 144 SELECT raw_file_mtime AS rawFileMtime, raw_file_size AS rawFileSize, index_version AS indexVersion 143 145 FROM sessions 144 146 WHERE file_path = ? ··· 153 155 154 156 export function deleteSessionByFilePath(db: Database, filePath: string): void { 155 157 const row = db 156 - .query("SELECT session_uuid AS sessionUuid FROM sessions WHERE file_path = ? LIMIT 1") 158 + .query<{ sessionUuid: string }, [string]>("SELECT session_uuid AS sessionUuid FROM sessions WHERE file_path = ? LIMIT 1") 157 159 .get(filePath) as { sessionUuid: string } | null; 158 160 159 161 if (!row) return; ··· 161 163 } 162 164 163 165 function deleteSessionByUuid(db: Database, sessionUuid: string): void { 164 - db.run("DELETE FROM sessions_fts WHERE session_uuid = ?", sessionUuid); 165 - db.run("DELETE FROM messages_fts WHERE session_uuid = ?", sessionUuid); 166 - db.run("DELETE FROM messages WHERE session_uuid = ?", sessionUuid); 167 - db.run("DELETE FROM sessions WHERE session_uuid = ?", sessionUuid); 166 + db.run("DELETE FROM sessions_fts WHERE session_uuid = ?", [sessionUuid]); 167 + db.run("DELETE FROM messages_fts WHERE session_uuid = ?", [sessionUuid]); 168 + db.run("DELETE FROM messages WHERE session_uuid = ?", [sessionUuid]); 169 + db.run("DELETE FROM sessions WHERE session_uuid = ?", [sessionUuid]); 168 170 } 169 171 170 172 export function replaceSession( ··· 176 178 ): void { 177 179 const tx = db.transaction(() => { 178 180 const existing = db 179 - .query("SELECT id FROM sessions WHERE session_uuid = ? OR file_path = ? LIMIT 1") 181 + .query<{ id: number }, [string, string]>("SELECT id FROM sessions WHERE session_uuid = ? OR file_path = ? LIMIT 1") 180 182 .get(session.sessionUuid, session.filePath) as { id: number } | null; 181 183 182 184 if (existing) { ··· 188 190 message_count = ?, raw_file_mtime = ?, raw_file_size = ?, index_version = ?, updated_at = CURRENT_TIMESTAMP 189 191 WHERE id = ? 190 192 `, 191 - session.sessionUuid, 192 - session.filePath, 193 - session.title, 194 - session.summaryText, 195 - session.compactText ?? "", 196 - session.reasoningSummaryText ?? "", 197 - session.cwd, 198 - session.model, 199 - session.startedAt, 200 - session.endedAt, 201 - session.messages.length, 202 - rawFileMtime, 203 - rawFileSize, 204 - indexVersion, 205 - existing.id, 193 + [ 194 + session.sessionUuid, 195 + session.filePath, 196 + session.title, 197 + session.summaryText, 198 + session.compactText ?? "", 199 + session.reasoningSummaryText ?? "", 200 + session.cwd, 201 + session.model, 202 + session.startedAt, 203 + session.endedAt, 204 + session.messages.length, 205 + rawFileMtime, 206 + rawFileSize, 207 + indexVersion, 208 + existing.id, 209 + ], 206 210 ); 207 211 } else { 208 212 db.run( ··· 213 217 message_count, raw_file_mtime, raw_file_size, index_version 214 218 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 215 219 `, 216 - session.sessionUuid, 217 - session.filePath, 218 - session.title, 219 - session.summaryText, 220 - session.compactText ?? "", 221 - session.reasoningSummaryText ?? "", 222 - session.cwd, 223 - session.model, 224 - session.startedAt, 225 - session.endedAt, 226 - session.messages.length, 227 - rawFileMtime, 228 - rawFileSize, 229 - indexVersion, 220 + [ 221 + session.sessionUuid, 222 + session.filePath, 223 + session.title, 224 + session.summaryText, 225 + session.compactText ?? "", 226 + session.reasoningSummaryText ?? "", 227 + session.cwd, 228 + session.model, 229 + session.startedAt, 230 + session.endedAt, 231 + session.messages.length, 232 + rawFileMtime, 233 + rawFileSize, 234 + indexVersion, 235 + ], 230 236 ); 231 237 } 232 238 233 239 const sessionRow = db 234 - .query("SELECT id FROM sessions WHERE session_uuid = ? LIMIT 1") 240 + .query<{ id: number }, [string]>("SELECT id FROM sessions WHERE session_uuid = ? LIMIT 1") 235 241 .get(session.sessionUuid) as { id: number }; 236 242 237 - db.run("DELETE FROM messages_fts WHERE session_uuid = ?", session.sessionUuid); 238 - db.run("DELETE FROM messages WHERE session_uuid = ?", session.sessionUuid); 239 - db.run("DELETE FROM sessions_fts WHERE rowid = ? OR session_uuid = ?", sessionRow.id, session.sessionUuid); 243 + db.run("DELETE FROM messages_fts WHERE session_uuid = ?", [session.sessionUuid]); 244 + db.run("DELETE FROM messages WHERE session_uuid = ?", [session.sessionUuid]); 245 + db.run("DELETE FROM sessions_fts WHERE rowid = ? OR session_uuid = ?", [sessionRow.id, session.sessionUuid]); 240 246 241 247 db.run( 242 248 ` 243 249 INSERT INTO sessions_fts(rowid, title, summary_text, compact_text, reasoning_summary_text, session_uuid) 244 250 VALUES (?, ?, ?, ?, ?, ?) 245 251 `, 246 - sessionRow.id, 247 - tokenizedText(session.title), 248 - tokenizedText(session.summaryText), 249 - tokenizedText(session.compactText ?? ""), 250 - tokenizedText(session.reasoningSummaryText ?? ""), 251 - session.sessionUuid, 252 + [ 253 + sessionRow.id, 254 + tokenizedText(session.title), 255 + tokenizedText(session.summaryText), 256 + tokenizedText(session.compactText ?? ""), 257 + tokenizedText(session.reasoningSummaryText ?? ""), 258 + session.sessionUuid, 259 + ], 252 260 ); 253 261 254 - const messageStmt = db.prepare(` 262 + const messageStmt = db.prepare<unknown, [number, string, number, string, string, string, string]>(` 255 263 INSERT INTO messages (session_id, session_uuid, seq, role, content_text, timestamp, source_kind) 256 264 VALUES (?, ?, ?, ?, ?, ?, ?) 257 265 `); 258 - const ftsStmt = db.prepare(` 266 + const ftsStmt = db.prepare<unknown, [number, string, string, number, string, string]>(` 259 267 INSERT INTO messages_fts(rowid, content_text, session_uuid, seq, role, timestamp) 260 268 VALUES (?, ?, ?, ?, ?, ?) 261 269 `); ··· 290 298 291 299 export function getSessionRecord(db: Database, sessionUuid: string): SessionRecord | null { 292 300 const row = db 293 - .query(` 301 + .query<SessionRecord & { filePath: string }, [string]>(` 294 302 SELECT 295 303 session_uuid AS sessionUuid, 296 304 file_path AS filePath, ··· 327 335 endSeq: number, 328 336 ): MessageRecord[] { 329 337 return db 330 - .query(` 338 + .query<MessageRecord, [string, number, number]>(` 331 339 SELECT 332 340 session_uuid AS sessionUuid, 333 341 seq, ··· 349 357 limit: number, 350 358 ): MessageRecord[] { 351 359 return db 352 - .query(` 360 + .query<MessageRecord, [string, number, number]>(` 353 361 SELECT 354 362 session_uuid AS sessionUuid, 355 363 seq, ··· 367 375 368 376 export function listSessions(db: Database, query: SessionListQuery): SessionListEntry[] { 369 377 const conditions: string[] = []; 370 - const params: Array<string | number> = []; 378 + const params: SqlParams = []; 371 379 if (query.cwd) { 372 380 // Substring match rather than prefix/equality: agent callers often pass 373 381 // the trailing segment of a project path, not the full canonical path. ··· 389 397 params.push(query.limit); 390 398 391 399 return db 392 - .query(` 400 + .query<SessionListEntry, typeof params>(` 393 401 SELECT 394 402 session_uuid AS sessionUuid, 395 403 title, ··· 435 443 436 444 export function getTopCwds(db: Database, limit: number): CwdCount[] { 437 445 return db 438 - .query(` 446 + .query<CwdCount, [number]>(` 439 447 SELECT cwd, COUNT(*) AS count 440 448 FROM sessions 441 449 WHERE cwd != ''
+2 -1
eval/manual-eval-core.test.ts
··· 1 1 import { describe, expect, test } from "bun:test"; 2 2 import { evaluateManualQuery } from "./manual-eval-core"; 3 + import type { FindResult } from "../types"; 3 4 4 5 describe("evaluateManualQuery", () => { 5 6 test("requires every configured predicate to match somewhere in top-k", () => { ··· 60 61 cwd: string; 61 62 snippet: string; 62 63 }> = {}, 63 - ) { 64 + ): FindResult { 64 65 return { 65 66 rank: overrides.rank ?? 1, 66 67 sessionUuid: overrides.sessionUuid ?? "session-a",
+2 -1
indexer.ts
··· 1 1 import { readdirSync, statSync } from "node:fs"; 2 + import type { Dirent } from "node:fs"; 2 3 import { join } from "node:path"; 3 4 import { DEFAULT_DB_PATH, INDEX_VERSION, ensureDataDir, resolveCodexDir } from "./env"; 4 5 import { deleteSessionByFilePath, getIndexedSessionMeta, openWriteDb, replaceSession } from "./db"; ··· 123 124 } 124 125 125 126 function walk(currentDir: string, files: string[]): void { 126 - let entries: ReturnType<typeof readdirSync>; 127 + let entries: Dirent<string>[]; 127 128 try { 128 129 entries = readdirSync(currentDir, { withFileTypes: true }); 129 130 } catch {
+5 -1
package.json
··· 42 42 }, 43 43 "scripts": { 44 44 "test": "bun test", 45 - "check": "bun test", 45 + "check": "tsc --noEmit && bun test", 46 46 "cxs": "bun run ./cli.ts", 47 47 "eval:manual": "bun run ./eval/run-manual-eval.ts" 48 48 }, 49 49 "dependencies": { 50 50 "chalk": "^5.6.2", 51 51 "commander": "^14.0.3" 52 + }, 53 + "devDependencies": { 54 + "@types/bun": "^1.3.13", 55 + "typescript": "^6.0.3" 52 56 } 53 57 }
+6 -16
query.test.ts
··· 35 35 `); 36 36 db.run( 37 37 "INSERT INTO threads (id, rollout_path, cwd, title, updated_at_ms) VALUES (?, ?, ?, ?, ?)", 38 - "11111111-1111-4111-8111-111111111111", 39 - "/tmp/a.jsonl", 40 - "/tmp/project", 41 - "older", 42 - 100, 38 + ["11111111-1111-4111-8111-111111111111", "/tmp/a.jsonl", "/tmp/project", "older", 100], 43 39 ); 44 40 db.run( 45 41 "INSERT INTO threads (id, rollout_path, cwd, title, updated_at_ms) VALUES (?, ?, ?, ?, ?)", 46 - "22222222-2222-4222-8222-222222222222", 47 - "/tmp/b.jsonl", 48 - "/tmp/project", 49 - "newer", 50 - 200, 42 + ["22222222-2222-4222-8222-222222222222", "/tmp/b.jsonl", "/tmp/project", "newer", 200], 51 43 ); 52 44 db.run( 53 45 "INSERT INTO threads (id, rollout_path, cwd, title, updated_at_ms) VALUES (?, ?, ?, ?, ?)", 54 - "33333333-3333-4333-8333-333333333333", 55 - "/tmp/c.jsonl", 56 - "/tmp/other", 57 - "other", 58 - 300, 46 + ["33333333-3333-4333-8333-333333333333", "/tmp/c.jsonl", "/tmp/other", "other", 300], 59 47 ); 60 48 db.close(); 61 49 ··· 247 235 248 236 const db = openReadDb(dbPath); 249 237 const row = db 250 - .query("SELECT summary_text AS summaryText FROM sessions WHERE session_uuid = ? LIMIT 1") 238 + .query<{ summaryText: string }, [string]>("SELECT summary_text AS summaryText FROM sessions WHERE session_uuid = ? LIMIT 1") 251 239 .get("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee") as { summaryText: string } | null; 252 240 db.close(); 253 241 ··· 271 259 filePath: join(base, "rollout.jsonl"), 272 260 title: "设置 ChatGPT 订阅取消提醒", 273 261 summaryText: "user: billing reminder | assistant: schedule a local notification", 262 + compactText: "", 263 + reasoningSummaryText: "", 274 264 cwd: "/tmp/title-only", 275 265 model: "gpt-5.4", 276 266 startedAt: "2026-04-24T01:00:00.000Z",
+10 -8
query.ts
··· 1 1 import { Database } from "bun:sqlite"; 2 + import type { SQLQueryBindings } from "bun:sqlite"; 2 3 import { statSync } from "node:fs"; 3 4 import { 4 5 getMessagesForPage, ··· 23 24 } from "./types"; 24 25 25 26 export { classifyQueryProfile } from "./ranking"; 27 + type SqlParams = SQLQueryBindings[]; 26 28 27 29 export function findSessions( 28 30 dbPath: string, ··· 109 111 const db = new Database(stateDbPath, { readonly: true }); 110 112 try { 111 113 const candidates = db 112 - .query(` 114 + .query<CurrentSessionCandidate, [string, number]>(` 113 115 SELECT 114 116 id AS sessionUuid, 115 117 title, ··· 166 168 167 169 if (query) { 168 170 const best = searchTopHitInSession(db, sessionUuid, query); 169 - if (best) return best.matchSeq; 171 + if (best && typeof best.matchSeq === "number") return best.matchSeq; 170 172 } 171 173 172 174 throw new Error("read-range requires explicit session_uuid plus either --seq or --query"); ··· 213 215 ): RawHitRow[] { 214 216 const matchExpr = buildFtsMatch(terms); 215 217 const conditions = [`messages_fts MATCH ?`]; 216 - const params: Array<string | number> = [matchExpr]; 218 + const params: SqlParams = [matchExpr]; 217 219 218 220 if (sessionUuid) { 219 221 conditions.push("m.session_uuid = ?"); ··· 222 224 params.push(limit); 223 225 224 226 return db 225 - .query(` 227 + .query<RawHitRow, typeof params>(` 226 228 SELECT 227 229 s.session_uuid AS sessionUuid, 228 230 s.title AS title, ··· 255 257 ): RawHitRow[] { 256 258 const matchExpr = buildFtsMatch(terms); 257 259 const rows = db 258 - .query(` 260 + .query<RawHitRow, [string, number]>(` 259 261 SELECT 260 262 s.session_uuid AS sessionUuid, 261 263 s.title AS title, ··· 286 288 287 289 function searchByLike(db: Database, query: string, limit: number, sessionUuid?: string): RawHitRow[] { 288 290 const conditions = ["lower(m.content_text) LIKE ? ESCAPE '\\'"]; 289 - const params: Array<string | number> = [`%${escapeLike(query.toLowerCase())}%`]; 291 + const params: SqlParams = [`%${escapeLike(query.toLowerCase())}%`]; 290 292 if (sessionUuid) { 291 293 conditions.push("m.session_uuid = ?"); 292 294 params.push(sessionUuid); ··· 294 296 params.push(limit); 295 297 296 298 const rows = db 297 - .query(` 299 + .query<RawHitRow & { contentText: string }, typeof params>(` 298 300 SELECT 299 301 s.session_uuid AS sessionUuid, 300 302 s.title AS title, ··· 327 329 328 330 function tableExists(db: Database, tableName: string): boolean { 329 331 const row = db 330 - .query("SELECT 1 FROM sqlite_master WHERE name = ? LIMIT 1") 332 + .query<unknown, [string]>("SELECT 1 FROM sqlite_master WHERE name = ? LIMIT 1") 331 333 .get(tableName); 332 334 return Boolean(row); 333 335 }
+15
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ESNext", 5 + "moduleResolution": "Bundler", 6 + "moduleDetection": "force", 7 + "strict": true, 8 + "noEmit": true, 9 + "allowImportingTsExtensions": true, 10 + "skipLibCheck": true, 11 + "types": ["bun-types"] 12 + }, 13 + "include": ["**/*.ts"], 14 + "exclude": ["node_modules", "data"] 15 + }