minecodex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/apps/dashboard/app.js +387 -0
  4. package/apps/dashboard/favicon.svg +18 -0
  5. package/apps/dashboard/index.html +70 -0
  6. package/apps/dashboard/styles.css +297 -0
  7. package/features/images/README.md +106 -0
  8. package/features/images/codex-feature.json +24 -0
  9. package/features/images/src/http-server.mjs +302 -0
  10. package/features/images/src/image-library.mjs +582 -0
  11. package/features/images/src/main.mjs +52 -0
  12. package/features/images/src/prompt-index.mjs +182 -0
  13. package/features/images/src/save-as.mjs +40 -0
  14. package/features/images/src/thread-metadata.mjs +93 -0
  15. package/features/images/web/app.js +1045 -0
  16. package/features/images/web/index.html +162 -0
  17. package/features/images/web/styles.css +994 -0
  18. package/features/model-slider/README.md +30 -0
  19. package/features/model-slider/THIRD_PARTY_NOTICES.md +24 -0
  20. package/features/model-slider/assets/providers/anthropic.svg +1 -0
  21. package/features/model-slider/assets/providers/deepseek.svg +1 -0
  22. package/features/model-slider/assets/providers/gemini.svg +1 -0
  23. package/features/model-slider/assets/providers/grok.svg +1 -0
  24. package/features/model-slider/assets/providers/hunyuan.svg +1 -0
  25. package/features/model-slider/assets/providers/kimi.svg +1 -0
  26. package/features/model-slider/assets/providers/minimax.svg +1 -0
  27. package/features/model-slider/assets/providers/openai.svg +1 -0
  28. package/features/model-slider/assets/providers/qwen.svg +1 -0
  29. package/features/model-slider/assets/providers/xiaomimimo.svg +1 -0
  30. package/features/model-slider/assets/providers/zhipu.svg +1 -0
  31. package/features/model-slider/assets/ui/sparkles.svg +18 -0
  32. package/features/model-slider/assets/ui/star-off.svg +17 -0
  33. package/features/model-slider/assets/ui/star.svg +15 -0
  34. package/features/model-slider/codex-feature.json +56 -0
  35. package/features/model-slider/vendor/cc-switch-LICENSE +21 -0
  36. package/features/model-slider/vendor/lucide-LICENSE +35 -0
  37. package/features/notes/README.md +106 -0
  38. package/features/notes/assets/icons/circle.svg +15 -0
  39. package/features/notes/assets/icons/ellipsis.svg +17 -0
  40. package/features/notes/assets/icons/external-link.svg +17 -0
  41. package/features/notes/assets/icons/list-todo.svg +19 -0
  42. package/features/notes/assets/icons/message-square-quote.svg +17 -0
  43. package/features/notes/assets/icons/paperclip.svg +15 -0
  44. package/features/notes/assets/icons/star-off.svg +17 -0
  45. package/features/notes/assets/icons/star.svg +15 -0
  46. package/features/notes/assets/icons/sticky-note.svg +16 -0
  47. package/features/notes/assets/icons/trash-2.svg +19 -0
  48. package/features/notes/codex-feature.json +90 -0
  49. package/features/notes/src/http-server.mjs +541 -0
  50. package/features/notes/src/main.mjs +54 -0
  51. package/features/notes/src/product.mjs +771 -0
  52. package/features/notes/web/app.js +1495 -0
  53. package/features/notes/web/codex-artifact-document.svg +1 -0
  54. package/features/notes/web/codex-attachment.svg +1 -0
  55. package/features/notes/web/codex-build.svg +1 -0
  56. package/features/notes/web/codex-code.svg +1 -0
  57. package/features/notes/web/codex-cplusplus.svg +1 -0
  58. package/features/notes/web/codex-css.svg +1 -0
  59. package/features/notes/web/codex-document.svg +1 -0
  60. package/features/notes/web/codex-file.svg +1 -0
  61. package/features/notes/web/codex-folder.svg +1 -0
  62. package/features/notes/web/codex-hashes.svg +1 -0
  63. package/features/notes/web/codex-html.svg +1 -0
  64. package/features/notes/web/codex-image.svg +1 -0
  65. package/features/notes/web/codex-java.svg +1 -0
  66. package/features/notes/web/codex-javascript.svg +1 -0
  67. package/features/notes/web/codex-json.svg +1 -0
  68. package/features/notes/web/codex-notebook.svg +1 -0
  69. package/features/notes/web/codex-pdf.svg +1 -0
  70. package/features/notes/web/codex-pencil.svg +3 -0
  71. package/features/notes/web/codex-php.svg +1 -0
  72. package/features/notes/web/codex-plus.svg +3 -0
  73. package/features/notes/web/codex-presentation.svg +1 -0
  74. package/features/notes/web/codex-python.svg +1 -0
  75. package/features/notes/web/codex-react.svg +1 -0
  76. package/features/notes/web/codex-rust.svg +1 -0
  77. package/features/notes/web/codex-shell.svg +1 -0
  78. package/features/notes/web/codex-skill.svg +1 -0
  79. package/features/notes/web/codex-spreadsheet.svg +1 -0
  80. package/features/notes/web/codex-task.svg +3 -0
  81. package/features/notes/web/codex-terminal.svg +1 -0
  82. package/features/notes/web/codex-toml.svg +1 -0
  83. package/features/notes/web/codex-typescript.svg +1 -0
  84. package/features/notes/web/codex-view-all.svg +3 -0
  85. package/features/notes/web/index.html +15 -0
  86. package/features/notes/web/styles.css +1155 -0
  87. package/package.json +67 -0
  88. package/packages/cli/bin/mcx.mjs +7 -0
  89. package/packages/cli/src/browser-exchange.mjs +31 -0
  90. package/packages/cli/src/commands.mjs +389 -0
  91. package/packages/cli/src/config.mjs +101 -0
  92. package/packages/cli/src/control-server.mjs +371 -0
  93. package/packages/cli/src/npm-adapter.mjs +40 -0
  94. package/packages/cli/src/paths.mjs +33 -0
  95. package/packages/cli/src/platform.mjs +368 -0
  96. package/packages/cli/src/runtime-manager.mjs +338 -0
  97. package/packages/runtime-host/README.md +171 -0
  98. package/packages/runtime-host/src/codex-runtime.mjs +4311 -0
  99. package/packages/runtime-host/src/feature-registry.mjs +827 -0
  100. package/packages/runtime-host/src/main.mjs +98 -0
  101. package/packages/runtime-host/src/pid-file.mjs +23 -0
@@ -0,0 +1,582 @@
1
+ import { watch } from "node:fs";
2
+ import {
3
+ mkdir,
4
+ open,
5
+ readdir,
6
+ stat,
7
+ } from "node:fs/promises";
8
+ import { createHash } from "node:crypto";
9
+ import path from "node:path";
10
+ import { DatabaseSync } from "node:sqlite";
11
+ import { ThreadPromptIndex } from "./prompt-index.mjs";
12
+
13
+ const PNG_EXTENSION = ".png";
14
+ const FALLBACK_RESCAN_INITIAL_DELAY_MS = 30_000;
15
+ const FALLBACK_RESCAN_MAX_DELAY_MS = 5 * 60_000;
16
+
17
+ function isPng(filePath) {
18
+ return path.extname(filePath).toLowerCase() === PNG_EXTENSION;
19
+ }
20
+
21
+ function imageIdFor(sourcePath) {
22
+ return createHash("sha256").update(sourcePath).digest("hex").slice(0, 24);
23
+ }
24
+
25
+ function buildWhere({ query = "", source = "all", threadId = null } = {}) {
26
+ const clauses = [];
27
+ const parameters = [];
28
+
29
+ if (threadId) {
30
+ clauses.push("images.thread_id = ?");
31
+ parameters.push(threadId);
32
+ }
33
+
34
+ if (source === "chat") {
35
+ clauses.push("thread_sources.source_kind = 'chat'");
36
+ } else if (source.startsWith("project:")) {
37
+ clauses.push("thread_sources.source_kind = 'project' AND thread_sources.project_id = ?");
38
+ parameters.push(source.slice("project:".length));
39
+ } else if (source.startsWith("project-name:")) {
40
+ clauses.push("thread_sources.source_kind = 'project' AND thread_sources.project_name = ?");
41
+ parameters.push(source.slice("project-name:".length));
42
+ } else if (source !== "all") {
43
+ throw new Error(`Unsupported image source: ${source}`);
44
+ }
45
+
46
+ const trimmed = query.trim();
47
+ if (trimmed) {
48
+ clauses.push(`(
49
+ images.thread_id LIKE ?
50
+ OR images.image_generation_item_id LIKE ?
51
+ OR COALESCE(images.prompt, '') LIKE ?
52
+ OR COALESCE(thread_sources.project_name, '') LIKE ?
53
+ OR COALESCE(thread_sources.thread_title, '') LIKE ?
54
+ )`);
55
+ parameters.push(...Array(5).fill(`%${trimmed}%`));
56
+ }
57
+
58
+ return {
59
+ sql: clauses.length ? `WHERE ${clauses.join(" AND ")}` : "",
60
+ parameters,
61
+ };
62
+ }
63
+
64
+ async function readPngSize(filePath) {
65
+ const handle = await open(filePath, "r");
66
+ try {
67
+ const header = Buffer.alloc(24);
68
+ const { bytesRead } = await handle.read(header, 0, header.length, 0);
69
+ const pngSignature = "89504e470d0a1a0a";
70
+ if (bytesRead < 24 || header.subarray(0, 8).toString("hex") !== pngSignature) {
71
+ return { width: null, height: null };
72
+ }
73
+ return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) };
74
+ } finally {
75
+ await handle.close();
76
+ }
77
+ }
78
+
79
+ async function collectPngFiles(directory, output = []) {
80
+ let entries;
81
+ try {
82
+ entries = await readdir(directory, { withFileTypes: true });
83
+ } catch (error) {
84
+ if (error.code === "ENOENT") return output;
85
+ throw error;
86
+ }
87
+
88
+ for (const entry of entries) {
89
+ const entryPath = path.join(directory, entry.name);
90
+ if (entry.isDirectory()) await collectPngFiles(entryPath, output);
91
+ else if (entry.isFile() && isPng(entryPath)) output.push(entryPath);
92
+ }
93
+ return output;
94
+ }
95
+
96
+ function rowToImage(row) {
97
+ return {
98
+ id: row.id,
99
+ threadId: row.thread_id,
100
+ imageGenerationItemId: row.image_generation_item_id,
101
+ sourcePath: row.source_path,
102
+ prompt: row.prompt,
103
+ width: row.width,
104
+ height: row.height,
105
+ byteLength: row.byte_length,
106
+ createdAt: row.created_at,
107
+ importedAt: row.imported_at,
108
+ sourceKind: row.source_kind ?? null,
109
+ projectId: row.project_id ?? null,
110
+ projectName: row.project_name ?? null,
111
+ threadTitle: row.thread_title ?? null,
112
+ archived: Boolean(row.archived),
113
+ };
114
+ }
115
+
116
+ export class ImageLibrary {
117
+ constructor({
118
+ sourceRoot,
119
+ dataDir,
120
+ sessionRoots = [],
121
+ threadMetadata = null,
122
+ logger = console,
123
+ watchSource = true,
124
+ promptIndexOptions = {},
125
+ watchFactory = watch,
126
+ }) {
127
+ this.sourceRoot = path.resolve(sourceRoot);
128
+ this.dataDir = path.resolve(dataDir);
129
+ this.sessionRoots = sessionRoots.map((sessionRoot) => path.resolve(sessionRoot));
130
+ this.threadMetadata = threadMetadata;
131
+ this.databasePath = path.join(this.dataDir, "library.sqlite");
132
+ this.logger = logger;
133
+ this.watchSource = watchSource;
134
+ this.watchFactory = watchFactory;
135
+ this.promptIndex = new ThreadPromptIndex({ sessionRoots: this.sessionRoots, ...promptIndexOptions });
136
+ this.database = null;
137
+ this.watcher = null;
138
+ this.pendingImports = new Map();
139
+ this.pendingRescan = null;
140
+ this.fallbackRescanTimer = null;
141
+ this.fallbackRescanDelayMs = FALLBACK_RESCAN_INITIAL_DELAY_MS;
142
+ this.rescanPromise = null;
143
+ this.initialScanPromise = null;
144
+ this.lifecycleVersion = 0;
145
+ this.closed = true;
146
+ }
147
+
148
+ async start({ deferInitialScan = false } = {}) {
149
+ const lifecycleVersion = ++this.lifecycleVersion;
150
+ this.closed = false;
151
+ this.initialScanPromise = null;
152
+ await mkdir(this.dataDir, { recursive: true });
153
+ await mkdir(this.sourceRoot, { recursive: true });
154
+ if (!this.isLifecycleActive(lifecycleVersion, { requireDatabase: false })) {
155
+ return { imported: 0, total: 0 };
156
+ }
157
+
158
+ this.database = new DatabaseSync(this.databasePath);
159
+ this.database.exec(`
160
+ CREATE TABLE IF NOT EXISTS images (
161
+ id TEXT PRIMARY KEY,
162
+ thread_id TEXT NOT NULL,
163
+ image_generation_item_id TEXT NOT NULL,
164
+ source_path TEXT NOT NULL UNIQUE,
165
+ prompt TEXT,
166
+ width INTEGER,
167
+ height INTEGER,
168
+ byte_length INTEGER NOT NULL,
169
+ source_mtime_ms REAL NOT NULL,
170
+ created_at TEXT NOT NULL,
171
+ imported_at TEXT NOT NULL
172
+ );
173
+ CREATE INDEX IF NOT EXISTS images_created_at_idx ON images(created_at DESC);
174
+ CREATE INDEX IF NOT EXISTS images_thread_id_idx ON images(thread_id);
175
+ CREATE TABLE IF NOT EXISTS thread_sources (
176
+ thread_id TEXT PRIMARY KEY,
177
+ source_kind TEXT NOT NULL,
178
+ project_id TEXT,
179
+ project_name TEXT,
180
+ thread_title TEXT NOT NULL,
181
+ archived INTEGER NOT NULL DEFAULT 0,
182
+ updated_at TEXT NOT NULL
183
+ );
184
+ `);
185
+ const threadSourceColumns = new Set(
186
+ this.database.prepare("PRAGMA table_info(thread_sources)").all().map((column) => column.name),
187
+ );
188
+ if (!threadSourceColumns.has("project_id")) {
189
+ this.database.exec("ALTER TABLE thread_sources ADD COLUMN project_id TEXT");
190
+ }
191
+ if (!threadSourceColumns.has("archived")) {
192
+ this.database.exec("ALTER TABLE thread_sources ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
193
+ }
194
+ this.database.exec(`
195
+ CREATE INDEX IF NOT EXISTS thread_sources_kind_idx ON thread_sources(source_kind);
196
+ CREATE INDEX IF NOT EXISTS thread_sources_project_id_idx ON thread_sources(project_id);
197
+ `);
198
+
199
+ const initialScan = () => this.completeInitialScan(lifecycleVersion);
200
+ if (deferInitialScan) {
201
+ return { imported: 0, total: this.count(), initialScan };
202
+ }
203
+ return initialScan();
204
+ }
205
+
206
+ isLifecycleActive(lifecycleVersion = this.lifecycleVersion, { requireDatabase = true } = {}) {
207
+ return !this.closed
208
+ && lifecycleVersion === this.lifecycleVersion
209
+ && (!requireDatabase || this.database !== null);
210
+ }
211
+
212
+ completeInitialScan(lifecycleVersion) {
213
+ if (!this.isLifecycleActive(lifecycleVersion)) {
214
+ return Promise.resolve({ imported: 0, total: 0 });
215
+ }
216
+ if (this.initialScanPromise) return this.initialScanPromise;
217
+ this.initialScanPromise = (async () => {
218
+ const initialImported = await this.rescan();
219
+ if (!this.isLifecycleActive(lifecycleVersion)) return { imported: 0, total: 0 };
220
+ this.startWatching(lifecycleVersion);
221
+ if (!this.isLifecycleActive(lifecycleVersion)) return { imported: 0, total: 0 };
222
+ const reconciliationImported = this.watcher ? await this.rescan() : 0;
223
+ if (!this.isLifecycleActive(lifecycleVersion)) return { imported: 0, total: 0 };
224
+ return { imported: initialImported + reconciliationImported, total: this.count() };
225
+ })();
226
+ return this.initialScanPromise;
227
+ }
228
+
229
+ startWatching(lifecycleVersion = this.lifecycleVersion) {
230
+ if (!this.watchSource || this.watcher || !this.isLifecycleActive(lifecycleVersion)) return;
231
+ const watcher = this.watchFactory(this.sourceRoot, { recursive: true }, (_eventType, filename) => {
232
+ if (!filename || !this.isLifecycleActive(lifecycleVersion)) return;
233
+ const sourcePath = path.resolve(this.sourceRoot, filename);
234
+ if (isPng(filename)) this.scheduleImport(sourcePath);
235
+ else if (!path.extname(sourcePath)) this.scheduleRescan();
236
+ });
237
+ if (!this.isLifecycleActive(lifecycleVersion)) {
238
+ watcher.close();
239
+ return;
240
+ }
241
+ this.watcher = watcher;
242
+ watcher.on("error", (error) => {
243
+ if (!this.isLifecycleActive(lifecycleVersion)) return;
244
+ this.logger.error("Image watcher failed", error);
245
+ this.startFallbackRescan();
246
+ });
247
+ }
248
+
249
+ count(options = {}) {
250
+ const { sql, parameters } = buildWhere(options);
251
+ return this.database.prepare(`
252
+ SELECT COUNT(*) AS count
253
+ FROM images
254
+ LEFT JOIN thread_sources ON thread_sources.thread_id = images.thread_id
255
+ ${sql}
256
+ `).get(...parameters).count;
257
+ }
258
+
259
+ list({ query = "", source = "all", threadId = null, sort = "desc", limit = null, offset = 0 } = {}) {
260
+ const { sql, parameters } = buildWhere({ query, source, threadId });
261
+ const pagination = Number.isInteger(limit) ? "LIMIT ? OFFSET ?" : "";
262
+ if (pagination) parameters.push(limit, offset);
263
+ const direction = sort === "asc" ? "ASC" : "DESC";
264
+ const rows = this.database
265
+ .prepare(`
266
+ SELECT images.*, thread_sources.source_kind, thread_sources.project_id,
267
+ thread_sources.project_name, thread_sources.thread_title, thread_sources.archived
268
+ FROM images
269
+ LEFT JOIN thread_sources ON thread_sources.thread_id = images.thread_id
270
+ ${sql}
271
+ ORDER BY images.created_at ${direction}, images.id ${direction}
272
+ ${pagination}
273
+ `)
274
+ .all(...parameters);
275
+ return rows.map(rowToImage);
276
+ }
277
+
278
+ facets() {
279
+ const chats = this.database.prepare(`
280
+ SELECT COUNT(*) AS count
281
+ FROM images
282
+ JOIN thread_sources ON thread_sources.thread_id = images.thread_id
283
+ WHERE thread_sources.source_kind = 'chat'
284
+ `).get().count;
285
+ const projects = this.database.prepare(`
286
+ SELECT thread_sources.project_name,
287
+ COUNT(*) AS count, MAX(images.created_at) AS latest_created_at
288
+ FROM images
289
+ JOIN thread_sources ON thread_sources.thread_id = images.thread_id
290
+ WHERE thread_sources.source_kind = 'project'
291
+ GROUP BY thread_sources.project_name
292
+ ORDER BY latest_created_at DESC, thread_sources.project_name COLLATE NOCASE ASC
293
+ `).all().map((row) => ({
294
+ scope: `project-name:${row.project_name}`,
295
+ name: row.project_name || "Project",
296
+ count: row.count,
297
+ latestCreatedAt: row.latest_created_at,
298
+ }));
299
+ return { total: this.count(), chats, projects };
300
+ }
301
+
302
+ countGroups({ source = "all" } = {}) {
303
+ const { sql, parameters } = buildWhere({ source });
304
+ return this.database.prepare(`
305
+ SELECT COUNT(DISTINCT images.thread_id) AS count
306
+ FROM images
307
+ LEFT JOIN thread_sources ON thread_sources.thread_id = images.thread_id
308
+ ${sql}
309
+ `).get(...parameters).count;
310
+ }
311
+
312
+ groups({ source = "all", limit = null, offset = 0, previewLimit = 4 } = {}) {
313
+ const { sql, parameters } = buildWhere({ source });
314
+ const pagination = Number.isInteger(limit) ? "LIMIT ? OFFSET ?" : "";
315
+ if (pagination) parameters.push(limit, offset);
316
+ const rows = this.database.prepare(`
317
+ SELECT images.thread_id, thread_sources.source_kind, thread_sources.project_id,
318
+ thread_sources.project_name, thread_sources.thread_title, thread_sources.archived,
319
+ COUNT(*) AS image_count, MIN(images.created_at) AS created_at,
320
+ MAX(images.created_at) AS updated_at
321
+ FROM images
322
+ LEFT JOIN thread_sources ON thread_sources.thread_id = images.thread_id
323
+ ${sql}
324
+ GROUP BY images.thread_id
325
+ ORDER BY updated_at DESC, images.thread_id DESC
326
+ ${pagination}
327
+ `).all(...parameters);
328
+
329
+ const previewsByThread = new Map();
330
+ if (rows.length && previewLimit > 0) {
331
+ const threadIds = rows.map((row) => row.thread_id);
332
+ const placeholders = threadIds.map(() => "?").join(", ");
333
+ const previewRows = this.database.prepare(`
334
+ SELECT * FROM (
335
+ SELECT images.*, thread_sources.source_kind, thread_sources.project_id,
336
+ thread_sources.project_name, thread_sources.thread_title, thread_sources.archived,
337
+ ROW_NUMBER() OVER (
338
+ PARTITION BY images.thread_id
339
+ ORDER BY images.created_at ASC, images.id ASC
340
+ ) AS preview_rank
341
+ FROM images
342
+ LEFT JOIN thread_sources ON thread_sources.thread_id = images.thread_id
343
+ WHERE images.thread_id IN (${placeholders})
344
+ )
345
+ WHERE preview_rank <= ?
346
+ ORDER BY thread_id ASC, created_at ASC, id ASC
347
+ `).all(...threadIds, previewLimit);
348
+ for (const previewRow of previewRows) {
349
+ const previews = previewsByThread.get(previewRow.thread_id) ?? [];
350
+ previews.push(rowToImage(previewRow));
351
+ previewsByThread.set(previewRow.thread_id, previews);
352
+ }
353
+ }
354
+
355
+ return rows.map((row) => ({
356
+ threadId: row.thread_id,
357
+ sourceKind: row.source_kind ?? null,
358
+ projectId: row.project_id ?? null,
359
+ projectName: row.project_name ?? null,
360
+ threadTitle: row.thread_title ?? null,
361
+ archived: Boolean(row.archived),
362
+ imageCount: row.image_count,
363
+ createdAt: row.created_at,
364
+ updatedAt: row.updated_at,
365
+ previews: previewsByThread.get(row.thread_id) ?? [],
366
+ }));
367
+ }
368
+
369
+ get(id) {
370
+ const row = this.database.prepare(`
371
+ SELECT images.*, thread_sources.source_kind, thread_sources.project_id,
372
+ thread_sources.project_name, thread_sources.thread_title, thread_sources.archived
373
+ FROM images
374
+ LEFT JOIN thread_sources ON thread_sources.thread_id = images.thread_id
375
+ WHERE images.id = ?
376
+ `).get(id);
377
+ return row ? rowToImage(row) : null;
378
+ }
379
+
380
+ async syncThreadMetadata(threadIds = null) {
381
+ if (!this.threadMetadata || !this.isLifecycleActive()) return;
382
+ const ids = threadIds ?? this.database
383
+ .prepare("SELECT DISTINCT thread_id FROM images")
384
+ .all()
385
+ .map((row) => row.thread_id);
386
+ const upsert = this.database.prepare(`
387
+ INSERT INTO thread_sources (
388
+ thread_id, source_kind, project_id, project_name, thread_title, archived, updated_at
389
+ )
390
+ VALUES (?, ?, ?, ?, ?, ?, ?)
391
+ ON CONFLICT(thread_id) DO UPDATE SET
392
+ source_kind = excluded.source_kind,
393
+ project_id = excluded.project_id,
394
+ project_name = excluded.project_name,
395
+ thread_title = excluded.thread_title,
396
+ archived = excluded.archived,
397
+ updated_at = excluded.updated_at
398
+ `);
399
+ const now = new Date().toISOString();
400
+ for (const threadId of ids) {
401
+ const metadata = await this.threadMetadata.get(threadId);
402
+ if (!this.isLifecycleActive()) return;
403
+ if (!metadata) continue;
404
+ upsert.run(
405
+ threadId,
406
+ metadata.sourceKind,
407
+ metadata.projectId ?? null,
408
+ metadata.projectName,
409
+ metadata.threadTitle,
410
+ metadata.archived ? 1 : 0,
411
+ now,
412
+ );
413
+ }
414
+ }
415
+
416
+ async hydratePrompt(id) {
417
+ const image = this.get(id);
418
+ if (!image || image.prompt || this.sessionRoots.length === 0) return image;
419
+ const prompt = await this.promptIndex.find(image.threadId, image.imageGenerationItemId);
420
+ if (!prompt) return image;
421
+ this.database.prepare("UPDATE images SET prompt = ? WHERE id = ?").run(prompt, id);
422
+ return this.get(id);
423
+ }
424
+
425
+ rescan() {
426
+ if (this.rescanPromise) return this.rescanPromise;
427
+ const scan = this.performRescan();
428
+ const shared = scan.finally(() => {
429
+ if (this.rescanPromise === shared) this.rescanPromise = null;
430
+ });
431
+ this.rescanPromise = shared;
432
+ return shared;
433
+ }
434
+
435
+ rescanStatus() {
436
+ return { inProgress: Boolean(this.rescanPromise) };
437
+ }
438
+
439
+ async performRescan() {
440
+ await this.threadMetadata?.refresh();
441
+ if (!this.isLifecycleActive()) return 0;
442
+ const files = await collectPngFiles(this.sourceRoot);
443
+ if (!this.isLifecycleActive()) return 0;
444
+ let imported = 0;
445
+ for (const filePath of files) {
446
+ if (!this.isLifecycleActive()) return imported;
447
+ if (await this.importImage(filePath)) imported += 1;
448
+ }
449
+ if (this.isLifecycleActive()) await this.syncThreadMetadata();
450
+ return imported;
451
+ }
452
+
453
+ scheduleImport(sourcePath) {
454
+ if (!this.isLifecycleActive()) return;
455
+ clearTimeout(this.pendingImports.get(sourcePath));
456
+ const timer = setTimeout(() => {
457
+ this.pendingImports.delete(sourcePath);
458
+ if (!this.isLifecycleActive()) return;
459
+ Promise.resolve(this.threadMetadata?.refresh())
460
+ .then(() => this.importImage(sourcePath))
461
+ .catch((error) => {
462
+ this.logger.warn(`Could not import ${sourcePath}`, error.message);
463
+ });
464
+ }, 400);
465
+ this.pendingImports.set(sourcePath, timer);
466
+ }
467
+
468
+ scheduleRescan() {
469
+ if (!this.isLifecycleActive()) return;
470
+ clearTimeout(this.pendingRescan);
471
+ this.pendingRescan = setTimeout(() => {
472
+ this.pendingRescan = null;
473
+ if (!this.isLifecycleActive()) return;
474
+ this.rescan().catch((error) => this.logger.warn("Could not rescan Images", error.message));
475
+ }, 400);
476
+ }
477
+
478
+ startFallbackRescan() {
479
+ if (this.fallbackRescanTimer !== null) return;
480
+ const tick = () => {
481
+ this.fallbackRescanTimer = null;
482
+ if (!this.isLifecycleActive()) return;
483
+ const delay = this.fallbackRescanDelayMs;
484
+ this.rescan()
485
+ .catch((error) => this.logger.warn("Could not rescan Images", error.message))
486
+ .finally(() => {
487
+ if (!this.isLifecycleActive()) return;
488
+ this.fallbackRescanDelayMs = Math.min(delay * 2, FALLBACK_RESCAN_MAX_DELAY_MS);
489
+ this.fallbackRescanTimer = setTimeout(tick, this.fallbackRescanDelayMs);
490
+ });
491
+ };
492
+ this.fallbackRescanTimer = setTimeout(tick, this.fallbackRescanDelayMs);
493
+ }
494
+
495
+ async importImage(sourcePath) {
496
+ if (!isPng(sourcePath) || !this.isLifecycleActive()) return false;
497
+
498
+ let sourceStat;
499
+ try {
500
+ sourceStat = await stat(sourcePath);
501
+ } catch (error) {
502
+ if (error.code === "ENOENT") {
503
+ this.database.prepare("DELETE FROM images WHERE source_path = ?").run(sourcePath);
504
+ return false;
505
+ }
506
+ throw error;
507
+ }
508
+ if (!this.isLifecycleActive()) return false;
509
+ if (!sourceStat.isFile()) return false;
510
+
511
+ const relativePath = path.relative(this.sourceRoot, sourcePath);
512
+ if (relativePath.startsWith("..")) return false;
513
+ const segments = relativePath.split(path.sep);
514
+ if (segments.length < 2) return false;
515
+
516
+ const threadId = segments.at(-2);
517
+ const imageGenerationItemId = path.basename(sourcePath, PNG_EXTENSION);
518
+ const id = imageIdFor(sourcePath);
519
+ const existing = this.database
520
+ .prepare("SELECT source_mtime_ms, byte_length FROM images WHERE id = ?")
521
+ .get(id);
522
+
523
+ if (
524
+ existing &&
525
+ existing.source_mtime_ms === sourceStat.mtimeMs &&
526
+ existing.byte_length === sourceStat.size
527
+ ) return false;
528
+
529
+ const { width, height } = await readPngSize(sourcePath);
530
+ if (!this.isLifecycleActive()) return false;
531
+ const now = new Date().toISOString();
532
+ const createdAt = new Date(sourceStat.birthtimeMs || sourceStat.mtimeMs).toISOString();
533
+
534
+ this.database
535
+ .prepare(`
536
+ INSERT INTO images (
537
+ id, thread_id, image_generation_item_id, source_path,
538
+ prompt, width, height, byte_length, source_mtime_ms,
539
+ created_at, imported_at
540
+ ) VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?)
541
+ ON CONFLICT(id) DO UPDATE SET
542
+ width = excluded.width,
543
+ height = excluded.height,
544
+ byte_length = excluded.byte_length,
545
+ source_mtime_ms = excluded.source_mtime_ms,
546
+ imported_at = excluded.imported_at
547
+ `)
548
+ .run(
549
+ id,
550
+ threadId,
551
+ imageGenerationItemId,
552
+ sourcePath,
553
+ width,
554
+ height,
555
+ sourceStat.size,
556
+ sourceStat.mtimeMs,
557
+ createdAt,
558
+ now,
559
+ );
560
+ this.promptIndex.invalidate(threadId);
561
+ await this.syncThreadMetadata([threadId]);
562
+ return true;
563
+ }
564
+
565
+ close() {
566
+ this.closed = true;
567
+ this.lifecycleVersion += 1;
568
+ this.watcher?.close();
569
+ this.watcher = null;
570
+ for (const timer of this.pendingImports.values()) clearTimeout(timer);
571
+ this.pendingImports.clear();
572
+ clearTimeout(this.pendingRescan);
573
+ this.pendingRescan = null;
574
+ clearTimeout(this.fallbackRescanTimer);
575
+ this.fallbackRescanTimer = null;
576
+ this.fallbackRescanDelayMs = FALLBACK_RESCAN_INITIAL_DELAY_MS;
577
+ this.promptIndex.close();
578
+ this.threadMetadata?.close();
579
+ this.database?.close();
580
+ this.database = null;
581
+ }
582
+ }
@@ -0,0 +1,52 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { ImageLibrary } from "./image-library.mjs";
5
+ import { createHttpServer } from "./http-server.mjs";
6
+ import { ThreadMetadataIndex } from "./thread-metadata.mjs";
7
+
8
+ const projectRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
9
+ const dataDir = process.env.CODEX_IMAGE_HOST_DATA_DIR ?? path.join(os.homedir(), ".codex-image-host");
10
+ const sourceRoot = process.env.CODEX_IMAGEGEN_ROOT ?? path.join(os.homedir(), ".codex", "generated_images");
11
+ const codexRoot = path.join(os.homedir(), ".codex");
12
+ const sessionRoots = [
13
+ process.env.CODEX_SESSION_ROOT ?? path.join(codexRoot, "sessions"),
14
+ path.join(codexRoot, "archived_sessions"),
15
+ ];
16
+ const port = Number(process.env.CODEX_IMAGE_HOST_PORT ?? 47831);
17
+ const threadMetadata = new ThreadMetadataIndex({
18
+ stateDatabasePath: process.env.CODEX_STATE_DATABASE ?? path.join(codexRoot, "state_5.sqlite"),
19
+ globalStatePath: process.env.CODEX_GLOBAL_STATE ?? path.join(codexRoot, ".codex-global-state.json"),
20
+ sessionIndexPath: process.env.CODEX_SESSION_INDEX ?? path.join(codexRoot, "session_index.jsonl"),
21
+ });
22
+
23
+ const library = new ImageLibrary({ sourceRoot, dataDir, sessionRoots, threadMetadata });
24
+ const startup = await library.start({ deferInitialScan: true });
25
+ const server = await createHttpServer({
26
+ library,
27
+ webDir: path.join(projectRoot, "web"),
28
+ port,
29
+ });
30
+
31
+ console.log(`Images: ${startup.total} indexed; initial scan running in background`);
32
+ console.log(`Library: ${server.url}`);
33
+
34
+ async function shutdown() {
35
+ await server.close();
36
+ library.close();
37
+ }
38
+
39
+ for (const signal of ["SIGINT", "SIGTERM"]) {
40
+ process.once(signal, async () => {
41
+ await shutdown();
42
+ process.exit(0);
43
+ });
44
+ }
45
+
46
+ void startup.initialScan()
47
+ .then(({ total, imported }) => {
48
+ console.log(`Images: ${total} total, ${imported} imported by initial scan`);
49
+ })
50
+ .catch((error) => {
51
+ console.error("Images initial scan failed", error);
52
+ });