Monorepo for Aesthetic.Computer
aesthetic.computer
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}