feat: auto-sync bound codex threads into conversations
This commit is contained in:
236
local-agent/codex-session-discovery.mjs
Normal file
236
local-agent/codex-session-discovery.mjs
Normal file
@@ -0,0 +1,236 @@
|
||||
import os from "node:os";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
function toIsoFromUnixSeconds(value) {
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return new Date(value * 1000).toISOString();
|
||||
}
|
||||
|
||||
function sanitizeDisplayName(raw, fallback) {
|
||||
const source = typeof raw === "string" ? raw : "";
|
||||
const firstLine = source
|
||||
.replace(/\u0000/g, "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
if (!firstLine) return fallback;
|
||||
const compact = firstLine.replace(/\s+/g, " ").trim();
|
||||
if (!compact) return fallback;
|
||||
return compact.length > 48 ? `${compact.slice(0, 45)}...` : compact;
|
||||
}
|
||||
|
||||
function fallbackDisplayName(thread, folderName) {
|
||||
const suffix = thread.id.replace(/-/g, "").slice(0, 6);
|
||||
if (thread.agentNickname) {
|
||||
return thread.agentNickname;
|
||||
}
|
||||
if (thread.agentRole) {
|
||||
return `${thread.agentRole} · ${suffix}`;
|
||||
}
|
||||
return `${folderName} · ${suffix}`;
|
||||
}
|
||||
|
||||
function loadThreadWorkspaceHints(globalStatePath) {
|
||||
if (!globalStatePath) return new Map();
|
||||
try {
|
||||
const parsed = JSON.parse(requireText(globalStatePath));
|
||||
return new Map(Object.entries(parsed["thread-workspace-root-hints"] ?? {}));
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function loadSessionNames(sessionIndexPath) {
|
||||
if (!sessionIndexPath) return new Map();
|
||||
const names = new Map();
|
||||
try {
|
||||
const raw = requireText(sessionIndexPath);
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
const parsed = JSON.parse(line);
|
||||
if (!parsed?.id) continue;
|
||||
const previous = names.get(parsed.id);
|
||||
if (!previous || String(parsed.updated_at ?? "") >= String(previous.updatedAt ?? "")) {
|
||||
names.set(parsed.id, {
|
||||
threadName: parsed.thread_name,
|
||||
updatedAt: parsed.updated_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return names;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function loadRecentThreadActivity(logsDbPath) {
|
||||
if (!logsDbPath) return new Map();
|
||||
try {
|
||||
const db = new DatabaseSync(logsDbPath, { readonly: true });
|
||||
try {
|
||||
const rows = db
|
||||
.prepare("SELECT thread_id, MAX(ts) AS latest_ts FROM logs WHERE thread_id IS NOT NULL GROUP BY thread_id")
|
||||
.all();
|
||||
return new Map(
|
||||
rows
|
||||
.filter((row) => typeof row.thread_id === "string" && Number.isFinite(row.latest_ts))
|
||||
.map((row) => [row.thread_id, Number(row.latest_ts)]),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function loadThreadsFromStateDb(stateDbPath) {
|
||||
if (!stateDbPath) return [];
|
||||
try {
|
||||
const db = new DatabaseSync(stateDbPath, { readonly: true });
|
||||
try {
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT id, cwd, updated_at, archived, title, agent_nickname, agent_role FROM threads WHERE archived = 0 ORDER BY updated_at DESC",
|
||||
)
|
||||
.all()
|
||||
.map((row) => ({
|
||||
id: String(row.id),
|
||||
cwd: String(row.cwd),
|
||||
updatedAtSeconds: Number(row.updated_at),
|
||||
archived: Boolean(row.archived),
|
||||
title: String(row.title ?? ""),
|
||||
agentNickname: typeof row.agent_nickname === "string" ? row.agent_nickname : "",
|
||||
agentRole: typeof row.agent_role === "string" ? row.agent_role : "",
|
||||
}));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseSessionMeta(line) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed?.type !== "session_meta" || !parsed?.payload?.id || !parsed?.payload?.cwd) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: String(parsed.payload.id),
|
||||
cwd: String(parsed.payload.cwd),
|
||||
updatedAtSeconds: Math.floor(new Date(parsed.payload.timestamp ?? parsed.timestamp ?? Date.now()).getTime() / 1000),
|
||||
archived: false,
|
||||
title: "",
|
||||
agentNickname: typeof parsed.payload.agent_nickname === "string" ? parsed.payload.agent_nickname : "",
|
||||
agentRole: typeof parsed.payload.agent_role === "string" ? parsed.payload.agent_role : "",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadThreadsFromSessions(sessionsDir) {
|
||||
if (!sessionsDir) return [];
|
||||
const pending = [resolve(sessionsDir)];
|
||||
const threads = [];
|
||||
while (pending.length > 0) {
|
||||
const dir = pending.pop();
|
||||
if (!dir) continue;
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = `${dir}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
pending.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
|
||||
try {
|
||||
const raw = await readFile(fullPath, "utf8");
|
||||
const firstLine = raw.split(/\r?\n/, 1)[0];
|
||||
const parsed = parseSessionMeta(firstLine);
|
||||
if (parsed) threads.push(parsed);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return threads;
|
||||
}
|
||||
|
||||
function requireText(filePath) {
|
||||
return readFileSync(resolve(filePath), "utf8");
|
||||
}
|
||||
|
||||
export async function discoverCodexProjectCandidates(options = {}) {
|
||||
const now = options.now instanceof Date ? options.now : new Date();
|
||||
const lookbackHours = Number.isFinite(options.lookbackHours) ? Number(options.lookbackHours) : 24;
|
||||
const cutoffSeconds = Math.floor(now.getTime() / 1000) - lookbackHours * 60 * 60;
|
||||
const sessionNames = loadSessionNames(options.sessionIndexPath ?? resolve(os.homedir(), ".codex/session_index.jsonl"));
|
||||
const workspaceHints = loadThreadWorkspaceHints(
|
||||
options.globalStatePath ?? resolve(os.homedir(), ".codex/.codex-global-state.json"),
|
||||
);
|
||||
const latestLogByThread = loadRecentThreadActivity(
|
||||
options.logsDbPath ?? resolve(os.homedir(), ".codex/logs_1.sqlite"),
|
||||
);
|
||||
|
||||
let threads = loadThreadsFromStateDb(
|
||||
options.stateDbPath ?? resolve(os.homedir(), ".codex/state_5.sqlite"),
|
||||
);
|
||||
if (threads.length === 0) {
|
||||
threads = await loadThreadsFromSessions(
|
||||
options.sessionsDir ?? resolve(os.homedir(), ".codex/sessions"),
|
||||
);
|
||||
}
|
||||
|
||||
const seenThreadIds = new Set();
|
||||
const candidates = [];
|
||||
for (const thread of threads) {
|
||||
if (!thread?.id || seenThreadIds.has(thread.id)) continue;
|
||||
const latestActivitySeconds = latestLogByThread.get(thread.id) ?? thread.updatedAtSeconds;
|
||||
if (!Number.isFinite(latestActivitySeconds) || latestActivitySeconds < cutoffSeconds) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenThreadIds.add(thread.id);
|
||||
const hintedPath = workspaceHints.get(thread.id);
|
||||
const folderPath = resolve(hintedPath || thread.cwd || "");
|
||||
const folderName = basename(folderPath);
|
||||
if (!folderName) continue;
|
||||
|
||||
const sessionName = sessionNames.get(thread.id)?.threadName;
|
||||
const displayName = sanitizeDisplayName(
|
||||
sessionName,
|
||||
sanitizeDisplayName(thread.title, fallbackDisplayName(thread, folderName)),
|
||||
);
|
||||
|
||||
candidates.push({
|
||||
folderName,
|
||||
folderRef: folderPath,
|
||||
threadId: thread.id,
|
||||
threadDisplayName: displayName,
|
||||
codexFolderRef: folderPath,
|
||||
codexThreadRef: thread.id,
|
||||
lastActiveAt: toIsoFromUnixSeconds(latestActivitySeconds) ?? now.toISOString(),
|
||||
suggestedImport: true,
|
||||
});
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.lastActiveAt.localeCompare(a.lastActiveAt));
|
||||
const projects = [...new Set(candidates.map((candidate) => candidate.folderName))].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
return {
|
||||
projects,
|
||||
projectCandidates: candidates,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user