Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

at 2c33ecdf94c8d90b421cc222b310e1e2d179f062 316 lines 8.3 kB view raw
1#!/usr/bin/env node 2// memory/codex-sync.mjs 3// Imports recent Codex user/assistant messages from local Codex session logs. 4 5import { existsSync } from "fs"; 6import { mkdir, readdir, readFile, stat, writeFile } from "fs/promises"; 7import { homedir } from "os"; 8import { basename, join } from "path"; 9 10import { commitEvent } from "./index.mjs"; 11 12const CODEX_SYNC_CURSOR_VERSION = 1; 13 14function asBool(value, fallback = false) { 15 if (value == null || value === "") return fallback; 16 return /^(1|true|yes|on)$/i.test(String(value)); 17} 18 19function nowIso() { 20 return new Date().toISOString(); 21} 22 23function parseArgs(argv) { 24 const args = { _: [] }; 25 for (let i = 0; i < argv.length; i += 1) { 26 const token = argv[i]; 27 if (!token.startsWith("--")) { 28 args._.push(token); 29 continue; 30 } 31 32 const key = token.slice(2); 33 const next = argv[i + 1]; 34 if (next && !next.startsWith("--")) { 35 args[key] = next; 36 i += 1; 37 } else { 38 args[key] = true; 39 } 40 } 41 return args; 42} 43 44function toInt(value, fallback) { 45 const n = Number(value); 46 return Number.isFinite(n) ? n : fallback; 47} 48 49function resolveMemoryHome() { 50 if (process.env.AGENT_MEMORY_HOME) return process.env.AGENT_MEMORY_HOME; 51 return join(homedir(), ".ac-agent-memory"); 52} 53 54function resolveCodexHome() { 55 if (process.env.CODEX_HOME) return process.env.CODEX_HOME; 56 return join(homedir(), ".codex"); 57} 58 59async function walkJsonlFiles(rootDir, out = []) { 60 if (!existsSync(rootDir)) return out; 61 const entries = await readdir(rootDir, { withFileTypes: true }); 62 for (const entry of entries) { 63 const fullPath = join(rootDir, entry.name); 64 if (entry.isDirectory()) { 65 await walkJsonlFiles(fullPath, out); 66 continue; 67 } 68 if (entry.isFile() && entry.name.endsWith(".jsonl")) { 69 out.push(fullPath); 70 } 71 } 72 return out; 73} 74 75async function getLatestSessionFiles(codexHome, limit = 3) { 76 const sessionsDir = join(codexHome, "sessions"); 77 const files = await walkJsonlFiles(sessionsDir, []); 78 const withTimes = []; 79 for (const file of files) { 80 try { 81 const fileStat = await stat(file); 82 withTimes.push({ 83 file, 84 mtimeMs: fileStat.mtimeMs, 85 }); 86 } catch { 87 // Ignore unreadable files. 88 } 89 } 90 91 withTimes.sort((a, b) => b.mtimeMs - a.mtimeMs); 92 return withTimes.slice(0, Math.max(1, limit)).map((entry) => entry.file); 93} 94 95function sanitizeSessionId(sessionId) { 96 return String(sessionId || "").replace(/[^a-zA-Z0-9._:-]/g, "-"); 97} 98 99function extractCodexSessionId(filePath) { 100 const name = basename(filePath, ".jsonl"); 101 const match = name.match( 102 /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i 103 ); 104 if (match?.[1]) return `codex:${match[1]}`; 105 return `codex:${name}`; 106} 107 108function isBoilerplateMessage(text) { 109 if (!text) return true; 110 if (text.includes("AGENTS.md instructions for /workspaces/aesthetic-computer")) { 111 return true; 112 } 113 if (text.includes("<permissions instructions>")) { 114 return true; 115 } 116 return false; 117} 118 119function truncateText(text, maxChars) { 120 if (text.length <= maxChars) return text; 121 return `${text.slice(0, maxChars)}\n\n[truncated by codex-sync]`; 122} 123 124function cursorFilePath() { 125 return join(resolveMemoryHome(), "imports", "codex-sync-cursor.json"); 126} 127 128async function loadCursor() { 129 const path = cursorFilePath(); 130 if (!existsSync(path)) { 131 return { version: CODEX_SYNC_CURSOR_VERSION, files: {} }; 132 } 133 try { 134 const parsed = JSON.parse(await readFile(path, "utf8")); 135 if (!parsed || typeof parsed !== "object") { 136 return { version: CODEX_SYNC_CURSOR_VERSION, files: {} }; 137 } 138 if (!parsed.files || typeof parsed.files !== "object") { 139 parsed.files = {}; 140 } 141 return parsed; 142 } catch { 143 return { version: CODEX_SYNC_CURSOR_VERSION, files: {} }; 144 } 145} 146 147async function saveCursor(cursor) { 148 const path = cursorFilePath(); 149 await mkdir(join(resolveMemoryHome(), "imports"), { recursive: true }); 150 await writeFile(path, `${JSON.stringify(cursor, null, 2)}\n`, "utf8"); 151} 152 153function parseJsonLine(line) { 154 try { 155 return JSON.parse(line); 156 } catch { 157 return null; 158 } 159} 160 161function messageFromRecord(record, includeAssistant) { 162 if (record?.type !== "event_msg") return null; 163 const payload = record.payload || {}; 164 if (!payload?.type) return null; 165 166 if (payload.type === "user_message") { 167 const text = typeof payload.message === "string" ? payload.message : ""; 168 if (!text.trim()) return null; 169 return { 170 role: "user", 171 text, 172 metadata: { 173 media_count: 174 (Array.isArray(payload.images) ? payload.images.length : 0) + 175 (Array.isArray(payload.local_images) ? payload.local_images.length : 0), 176 }, 177 }; 178 } 179 180 if (includeAssistant && payload.type === "agent_message") { 181 const text = typeof payload.message === "string" ? payload.message : ""; 182 if (!text.trim()) return null; 183 return { 184 role: "assistant", 185 text, 186 metadata: {}, 187 }; 188 } 189 190 return null; 191} 192 193export async function syncCodexSessions(options = {}) { 194 const codexHome = options.codexHome || resolveCodexHome(); 195 const includeAssistant = asBool( 196 process.env.AGENT_MEMORY_CODEX_INCLUDE_ASSISTANT, 197 false 198 ); 199 const maxSessions = Math.max(1, toInt(options.maxSessions, 3)); 200 const maxEvents = Math.max(1, toInt(options.maxEvents, 120)); 201 const maxChars = Math.max(500, toInt(options.maxChars, 20000)); 202 203 if (!existsSync(codexHome)) { 204 return { 205 synced_events: 0, 206 scanned_sessions: 0, 207 skipped: "codex-home-missing", 208 codex_home: codexHome, 209 }; 210 } 211 212 const sessionFiles = await getLatestSessionFiles(codexHome, maxSessions); 213 if (sessionFiles.length === 0) { 214 return { 215 synced_events: 0, 216 scanned_sessions: 0, 217 skipped: "no-codex-sessions", 218 codex_home: codexHome, 219 }; 220 } 221 222 const cursor = await loadCursor(); 223 let syncedEvents = 0; 224 let scannedSessions = 0; 225 let reachedEventCap = false; 226 227 for (const file of sessionFiles) { 228 scannedSessions += 1; 229 const raw = await readFile(file, "utf8"); 230 const lines = raw 231 .split("\n") 232 .map((line) => line.trim()) 233 .filter(Boolean); 234 235 const existing = cursor.files[file] || {}; 236 const startIndex = Math.min(toInt(existing.lines, 0), lines.length); 237 const pending = lines.slice(startIndex); 238 const sessionId = sanitizeSessionId(extractCodexSessionId(file)); 239 let consumedLines = startIndex; 240 241 for (const line of pending) { 242 if (syncedEvents >= maxEvents) { 243 reachedEventCap = true; 244 break; 245 } 246 consumedLines += 1; 247 248 const record = parseJsonLine(line); 249 if (!record) continue; 250 251 const message = messageFromRecord(record, includeAssistant); 252 if (!message) continue; 253 if (isBoilerplateMessage(message.text)) continue; 254 255 const text = truncateText(message.text, maxChars); 256 257 await commitEvent({ 258 sessionId, 259 provider: "codex", 260 role: message.role, 261 source: "codex-sync", 262 project: process.env.AGENT_MEMORY_PROJECT || "aesthetic-computer", 263 model: null, 264 text, 265 context: { 266 codex_file: file, 267 codex_timestamp: record.timestamp || null, 268 }, 269 metadata: { 270 codex_sync: true, 271 imported_at: nowIso(), 272 ...(message.metadata || {}), 273 }, 274 title: "Codex Session", 275 }); 276 277 syncedEvents += 1; 278 } 279 280 cursor.files[file] = { 281 lines: consumedLines, 282 updated_at: nowIso(), 283 }; 284 285 if (reachedEventCap) { 286 break; 287 } 288 } 289 290 await saveCursor(cursor); 291 292 return { 293 synced_events: syncedEvents, 294 scanned_sessions: scannedSessions, 295 codex_home: codexHome, 296 include_assistant: includeAssistant, 297 }; 298} 299 300async function main() { 301 const args = parseArgs(process.argv.slice(2)); 302 const result = await syncCodexSessions({ 303 maxSessions: args["max-sessions"], 304 maxEvents: args["max-events"], 305 maxChars: args["max-chars"], 306 codexHome: args["codex-home"], 307 }); 308 console.log(JSON.stringify(result, null, 2)); 309} 310 311if (import.meta.url === `file://${process.argv[1]}`) { 312 main().catch((error) => { 313 console.error(`codex-sync: ${error.message}`); 314 process.exit(1); 315 }); 316}