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.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/apps/dashboard/app.js +387 -0
- package/apps/dashboard/favicon.svg +18 -0
- package/apps/dashboard/index.html +70 -0
- package/apps/dashboard/styles.css +297 -0
- package/features/images/README.md +106 -0
- package/features/images/codex-feature.json +24 -0
- package/features/images/src/http-server.mjs +302 -0
- package/features/images/src/image-library.mjs +582 -0
- package/features/images/src/main.mjs +52 -0
- package/features/images/src/prompt-index.mjs +182 -0
- package/features/images/src/save-as.mjs +40 -0
- package/features/images/src/thread-metadata.mjs +93 -0
- package/features/images/web/app.js +1045 -0
- package/features/images/web/index.html +162 -0
- package/features/images/web/styles.css +994 -0
- package/features/model-slider/README.md +30 -0
- package/features/model-slider/THIRD_PARTY_NOTICES.md +24 -0
- package/features/model-slider/assets/providers/anthropic.svg +1 -0
- package/features/model-slider/assets/providers/deepseek.svg +1 -0
- package/features/model-slider/assets/providers/gemini.svg +1 -0
- package/features/model-slider/assets/providers/grok.svg +1 -0
- package/features/model-slider/assets/providers/hunyuan.svg +1 -0
- package/features/model-slider/assets/providers/kimi.svg +1 -0
- package/features/model-slider/assets/providers/minimax.svg +1 -0
- package/features/model-slider/assets/providers/openai.svg +1 -0
- package/features/model-slider/assets/providers/qwen.svg +1 -0
- package/features/model-slider/assets/providers/xiaomimimo.svg +1 -0
- package/features/model-slider/assets/providers/zhipu.svg +1 -0
- package/features/model-slider/assets/ui/sparkles.svg +18 -0
- package/features/model-slider/assets/ui/star-off.svg +17 -0
- package/features/model-slider/assets/ui/star.svg +15 -0
- package/features/model-slider/codex-feature.json +56 -0
- package/features/model-slider/vendor/cc-switch-LICENSE +21 -0
- package/features/model-slider/vendor/lucide-LICENSE +35 -0
- package/features/notes/README.md +106 -0
- package/features/notes/assets/icons/circle.svg +15 -0
- package/features/notes/assets/icons/ellipsis.svg +17 -0
- package/features/notes/assets/icons/external-link.svg +17 -0
- package/features/notes/assets/icons/list-todo.svg +19 -0
- package/features/notes/assets/icons/message-square-quote.svg +17 -0
- package/features/notes/assets/icons/paperclip.svg +15 -0
- package/features/notes/assets/icons/star-off.svg +17 -0
- package/features/notes/assets/icons/star.svg +15 -0
- package/features/notes/assets/icons/sticky-note.svg +16 -0
- package/features/notes/assets/icons/trash-2.svg +19 -0
- package/features/notes/codex-feature.json +90 -0
- package/features/notes/src/http-server.mjs +541 -0
- package/features/notes/src/main.mjs +54 -0
- package/features/notes/src/product.mjs +771 -0
- package/features/notes/web/app.js +1495 -0
- package/features/notes/web/codex-artifact-document.svg +1 -0
- package/features/notes/web/codex-attachment.svg +1 -0
- package/features/notes/web/codex-build.svg +1 -0
- package/features/notes/web/codex-code.svg +1 -0
- package/features/notes/web/codex-cplusplus.svg +1 -0
- package/features/notes/web/codex-css.svg +1 -0
- package/features/notes/web/codex-document.svg +1 -0
- package/features/notes/web/codex-file.svg +1 -0
- package/features/notes/web/codex-folder.svg +1 -0
- package/features/notes/web/codex-hashes.svg +1 -0
- package/features/notes/web/codex-html.svg +1 -0
- package/features/notes/web/codex-image.svg +1 -0
- package/features/notes/web/codex-java.svg +1 -0
- package/features/notes/web/codex-javascript.svg +1 -0
- package/features/notes/web/codex-json.svg +1 -0
- package/features/notes/web/codex-notebook.svg +1 -0
- package/features/notes/web/codex-pdf.svg +1 -0
- package/features/notes/web/codex-pencil.svg +3 -0
- package/features/notes/web/codex-php.svg +1 -0
- package/features/notes/web/codex-plus.svg +3 -0
- package/features/notes/web/codex-presentation.svg +1 -0
- package/features/notes/web/codex-python.svg +1 -0
- package/features/notes/web/codex-react.svg +1 -0
- package/features/notes/web/codex-rust.svg +1 -0
- package/features/notes/web/codex-shell.svg +1 -0
- package/features/notes/web/codex-skill.svg +1 -0
- package/features/notes/web/codex-spreadsheet.svg +1 -0
- package/features/notes/web/codex-task.svg +3 -0
- package/features/notes/web/codex-terminal.svg +1 -0
- package/features/notes/web/codex-toml.svg +1 -0
- package/features/notes/web/codex-typescript.svg +1 -0
- package/features/notes/web/codex-view-all.svg +3 -0
- package/features/notes/web/index.html +15 -0
- package/features/notes/web/styles.css +1155 -0
- package/package.json +67 -0
- package/packages/cli/bin/mcx.mjs +7 -0
- package/packages/cli/src/browser-exchange.mjs +31 -0
- package/packages/cli/src/commands.mjs +389 -0
- package/packages/cli/src/config.mjs +101 -0
- package/packages/cli/src/control-server.mjs +371 -0
- package/packages/cli/src/npm-adapter.mjs +40 -0
- package/packages/cli/src/paths.mjs +33 -0
- package/packages/cli/src/platform.mjs +368 -0
- package/packages/cli/src/runtime-manager.mjs +338 -0
- package/packages/runtime-host/README.md +171 -0
- package/packages/runtime-host/src/codex-runtime.mjs +4311 -0
- package/packages/runtime-host/src/feature-registry.mjs +827 -0
- package/packages/runtime-host/src/main.mjs +98 -0
- 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
|
+
});
|