// Codex 会话 @ 模糊匹配补全扩展 // // 仓库地址: https://github.com/toosean/pi-codex-session // // 在 pi 输入框输入 `@` 后,按标题模糊匹配本机 Codex 会话(~/.codex), // 选中后插入 `[@标题](codex://threads/)` 引用 + 触发 codex-session skill 的指令。 // // 数据源优先级: // 1. ~/.codex/state_5.sqlite threads 表(node:sqlite 直读,含 title/cwd/branch/时间) // 2. ~/.codex/session_index.jsonl(兜底) // 排除 archived=1 与无标题会话;结果按最近更新排序。 import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { type AutocompleteItem, type AutocompleteProvider, type AutocompleteSuggestions, fuzzyFilter, } from "@earendil-works/pi-tui"; import { DatabaseSync } from "node:sqlite"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import os from "node:os"; const MAX_SUGGESTIONS = 20; const CACHE_TTL_MS = 30_000; type CodexThread = { id: string; title: string; cwd: string; branch: string; updatedAtMs: number; }; function codexHome(): string { return process.env.CODEX_HOME || join(os.homedir(), ".codex"); } // ---- 数据源 1:state_5.sqlite(主) ---- function loadFromSqlite(dbPath: string): CodexThread[] { const db = new DatabaseSync(dbPath, { readOnly: true }); try { const stmt = db.prepare( `SELECT id, title, cwd, git_branch, updated_at_ms, archived FROM threads WHERE archived = 0 AND title IS NOT NULL AND title != ''`, ); const rows = stmt.all() as Array>; return rows.map((r) => ({ id: String(r.id), title: String(r.title), cwd: String(r.cwd ?? ""), branch: String(r.git_branch ?? ""), updatedAtMs: Number(r.updated_at_ms ?? r.updated_at ?? 0), })); } finally { db.close(); } } // ---- 数据源 2:session_index.jsonl(兜底) ---- function loadFromIndex(indexPath: string): CodexThread[] { const lines = readFileSync(indexPath, "utf8").split("\n").filter(Boolean); return lines .map((line) => { try { const obj = JSON.parse(line) as Record; return { id: String(obj.id ?? ""), title: String(obj.thread_name ?? ""), cwd: "", branch: "", updatedAtMs: obj.updated_at ? Date.parse(String(obj.updated_at)) : 0, }; } catch { return null; } }) .filter((t): t is CodexThread => !!t && t.id !== "" && t.title !== ""); } let cache: { threads: CodexThread[]; loadedAt: number } | undefined; async function getThreads(): Promise { const now = Date.now(); if (cache && now - cache.loadedAt < CACHE_TTL_MS) { return cache.threads; } const home = codexHome(); const candidates: Array<[string, "sqlite" | "index"]> = [ [join(home, "state_5.sqlite"), "sqlite"], [join(home, "session_index.jsonl"), "index"], ]; for (const [path, kind] of candidates) { if (!existsSync(path)) continue; try { const threads = kind === "sqlite" ? loadFromSqlite(path) : loadFromIndex(path); if (threads.length > 0) { threads.sort((a, b) => b.updatedAtMs - a.updatedAtMs); cache = { threads, loadedAt: now }; return threads; } } catch { // 数据源失败,尝试下一个 } } return undefined; } // ---- 匹配与展示 ---- function extractAtToken(textBeforeCursor: string): string | undefined { const match = textBeforeCursor.match(/(?:^|[ \t])@([^\s@]*)$/); return match?.[1]; } function relativeTime(ts: number): string { const diff = Date.now() - ts; const minutes = Math.max(1, Math.floor(diff / 60_000)); if (minutes < 60) return `${minutes}分钟前`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}小时前`; return `${Math.floor(hours / 24)}天前`; } function formatDescription(t: CodexThread): string { const parts: string[] = []; if (t.cwd) { const segs = t.cwd.split("/"); parts.push(segs.slice(-2).join("/")); } if (t.branch && t.branch !== "-") parts.push(`branch:${t.branch}`); if (t.updatedAtMs > 0) parts.push(relativeTime(t.updatedAtMs)); return parts.join(" | "); } function sanitizeTitle(title: string): string { const cleaned = title.replace(/[\[\]()]/g, "").trim(); return cleaned.length > 60 ? cleaned.slice(0, 60) + "…" : cleaned; } function formatItem(t: CodexThread): AutocompleteItem { const safeTitle = sanitizeTitle(t.title); return { value: `[@${safeTitle}](codex://threads/${t.id})`, label: t.title.length > 60 ? t.title.slice(0, 60) + "…" : t.title, description: formatDescription(t), }; } function filterThreads(threads: CodexThread[], query: string): AutocompleteItem[] { if (!query.trim()) { return threads.slice(0, MAX_SUGGESTIONS).map(formatItem); } return fuzzyFilter(threads, query, (t) => t.title) .slice(0, MAX_SUGGESTIONS) .map(formatItem); } // ---- Provider 注册 ---- function createProvider( current: AutocompleteProvider, getThreadsFn: () => Promise, ): AutocompleteProvider { return { triggerCharacters: ["@"], async getSuggestions(lines, cursorLine, cursorCol, options): Promise { const currentLine = lines[cursorLine] ?? ""; const beforeCursor = currentLine.slice(0, cursorCol); const token = extractAtToken(beforeCursor); if (token === undefined) { return current.getSuggestions(lines, cursorLine, cursorCol, options); } const threads = await getThreadsFn(); if (options.signal.aborted || !threads || threads.length === 0) { return current.getSuggestions(lines, cursorLine, cursorCol, options); } const items = filterThreads(threads, token); if (items.length === 0) { return current.getSuggestions(lines, cursorLine, cursorCol, options); } return { items, prefix: `@${token}` }; }, applyCompletion(lines, cursorLine, cursorCol, item, prefix) { return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix); }, shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true; }, }; } export default function (pi: ExtensionAPI): void { pi.on("session_start", (_event, ctx) => { ctx.ui.addAutocompleteProvider((current) => createProvider(current, getThreads)); }); }