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,771 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { EventEmitter } from "node:events";
3
+ import { chmod, mkdir, open as openFile, realpath, stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
6
+
7
+ const ORDER_STEP = 1024;
8
+ const DEFAULT_FILE_CLEANUP_INTERVAL_MS = 5_000;
9
+ const DEFAULT_FILE_CLEANUP_CONCURRENCY = 8;
10
+ const DEFAULT_FILE_STAT_TIMEOUT_MS = 1_500;
11
+ const DEFAULT_FILE_ADD_CONCURRENCY = 8;
12
+ const SUMMARY_ITEM_LIMIT = 5;
13
+ const TODO_CHANGE_FIELDS = new Set(["body", "completed"]);
14
+ const PROMPT_CHANGE_FIELDS = new Set(["title", "body", "starred"]);
15
+
16
+ function scheduleCleanupOnNextTurn(callback) {
17
+ setImmediate(callback);
18
+ }
19
+
20
+ const MIME_TYPES = new Map([
21
+ [".avif", "image/avif"],
22
+ [".css", "text/css"],
23
+ [".csv", "text/csv"],
24
+ [".doc", "application/msword"],
25
+ [".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
26
+ [".gif", "image/gif"],
27
+ [".heic", "image/heic"],
28
+ [".html", "text/html"],
29
+ [".jpeg", "image/jpeg"],
30
+ [".jpg", "image/jpeg"],
31
+ [".js", "text/javascript"],
32
+ [".json", "application/json"],
33
+ [".md", "text/markdown"],
34
+ [".mov", "video/quicktime"],
35
+ [".mp3", "audio/mpeg"],
36
+ [".mp4", "video/mp4"],
37
+ [".pdf", "application/pdf"],
38
+ [".png", "image/png"],
39
+ [".ppt", "application/vnd.ms-powerpoint"],
40
+ [".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"],
41
+ [".svg", "image/svg+xml"],
42
+ [".txt", "text/plain"],
43
+ [".webp", "image/webp"],
44
+ [".xls", "application/vnd.ms-excel"],
45
+ [".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
46
+ [".xml", "application/xml"],
47
+ [".zip", "application/zip"],
48
+ ]);
49
+
50
+ export class ProductError extends Error {
51
+ constructor(message, { code = "INVALID_REQUEST", status = 400, details = null } = {}) {
52
+ super(message);
53
+ this.name = "ProductError";
54
+ this.code = code;
55
+ this.status = status;
56
+ this.details = details;
57
+ }
58
+ }
59
+
60
+ function requiredBody(value, field = "body") {
61
+ if (typeof value !== "string" || value.trim().length === 0) {
62
+ throw new ProductError(`${field} must contain text`, { code: "EMPTY_BODY" });
63
+ }
64
+ return value;
65
+ }
66
+
67
+ function optionalTitle(value) {
68
+ if (value == null) return "";
69
+ if (typeof value !== "string") {
70
+ throw new ProductError("title must be a string", { code: "INVALID_TITLE" });
71
+ }
72
+ return value.trim();
73
+ }
74
+
75
+ function requiredObject(value, field = "request body") {
76
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
77
+ throw new ProductError(`${field} must be a JSON object`, { code: "INVALID_BODY" });
78
+ }
79
+ return value;
80
+ }
81
+
82
+ function requiredChanges(value, allowedFields) {
83
+ const changes = requiredObject(value, "changes");
84
+ const fields = Object.keys(changes);
85
+ if (fields.length === 0 || fields.some((field) => !allowedFields.has(field))) {
86
+ throw new ProductError("changes must contain only supported fields", {
87
+ code: "INVALID_CHANGES",
88
+ details: { allowedFields: [...allowedFields] },
89
+ });
90
+ }
91
+ return changes;
92
+ }
93
+
94
+ async function mapWithConcurrency(values, concurrency, operation) {
95
+ const results = new Array(values.length);
96
+ let nextIndex = 0;
97
+ const workers = Array.from(
98
+ { length: Math.min(concurrency, values.length) },
99
+ async () => {
100
+ while (nextIndex < values.length) {
101
+ const index = nextIndex;
102
+ nextIndex += 1;
103
+ results[index] = await operation(values[index], index);
104
+ }
105
+ },
106
+ );
107
+ await Promise.all(workers);
108
+ return results;
109
+ }
110
+
111
+ async function statWithin(fileStatOperations, capacity, fileStat, filePath, timeoutMs) {
112
+ if (fileStatOperations.size >= capacity) return null;
113
+ const settled = Promise.resolve()
114
+ .then(() => fileStat(filePath))
115
+ .then(
116
+ (info) => ({ status: "fulfilled", info }),
117
+ (error) => ({ status: "rejected", error }),
118
+ );
119
+ fileStatOperations.add(settled);
120
+ void settled.then(() => fileStatOperations.delete(settled));
121
+ let timer;
122
+ try {
123
+ const outcome = await Promise.race([
124
+ settled,
125
+ new Promise((resolve) => {
126
+ timer = setTimeout(() => resolve({ status: "timed-out" }), timeoutMs);
127
+ timer.unref?.();
128
+ }),
129
+ ]);
130
+ if (outcome.status === "timed-out") return null;
131
+ if (outcome.status === "rejected") throw outcome.error;
132
+ return outcome.info;
133
+ } finally {
134
+ clearTimeout(timer);
135
+ }
136
+ }
137
+
138
+ function mapTodo(row) {
139
+ return {
140
+ id: row.id,
141
+ body: row.body,
142
+ order: row.sort_order,
143
+ createdAt: row.created_at,
144
+ updatedAt: row.updated_at,
145
+ completedAt: row.completed_at,
146
+ };
147
+ }
148
+
149
+ function promptSummary(title, body) {
150
+ if (title.trim()) return title.trim();
151
+ let start = 0;
152
+ while (start <= body.length) {
153
+ const newline = body.indexOf("\n", start);
154
+ const end = newline === -1 ? body.length : newline;
155
+ const line = body.slice(start, end).replace(/\r$/, "").trim();
156
+ if (line) return line;
157
+ if (newline === -1) break;
158
+ start = newline + 1;
159
+ }
160
+ return "Untitled prompt";
161
+ }
162
+
163
+ function mapPrompt(row) {
164
+ return {
165
+ id: row.id,
166
+ title: row.title,
167
+ body: row.body,
168
+ summary: promptSummary(row.title, row.body),
169
+ starred: Boolean(row.starred),
170
+ createdAt: row.created_at,
171
+ updatedAt: row.updated_at,
172
+ lastUsedAt: row.last_used_at,
173
+ };
174
+ }
175
+
176
+ function mapFile(row) {
177
+ return {
178
+ id: row.id,
179
+ path: row.canonical_path,
180
+ name: row.display_name,
181
+ mimeType: row.mime_type,
182
+ size: row.size,
183
+ addedAt: row.added_at,
184
+ lastCalledAt: row.last_called_at,
185
+ };
186
+ }
187
+
188
+ function mimeTypeFor(filePath) {
189
+ return MIME_TYPES.get(path.extname(filePath).toLowerCase()) ?? "application/octet-stream";
190
+ }
191
+
192
+ function isConfirmedMissing(error) {
193
+ return error?.code === "ENOENT" || error?.code === "ENOTDIR";
194
+ }
195
+
196
+ function surfacesForReason(reason) {
197
+ const domain = String(reason).split(":", 1)[0];
198
+ if (domain === "todo") return ["summary", "todo", "editor:todo"];
199
+ if (domain === "prompt") return ["summary", "prompts", "editor:prompt"];
200
+ if (domain === "file" || domain === "files") return ["summary", "files"];
201
+ return ["summary", "todo", "prompts", "files"];
202
+ }
203
+
204
+ export class CodexNotesProduct extends EventEmitter {
205
+ constructor({
206
+ databasePath,
207
+ clock = () => Date.now(),
208
+ cleanupClock = () => Date.now(),
209
+ fileCleanupIntervalMs = DEFAULT_FILE_CLEANUP_INTERVAL_MS,
210
+ fileCleanupConcurrency = DEFAULT_FILE_CLEANUP_CONCURRENCY,
211
+ fileStatTimeoutMs = DEFAULT_FILE_STAT_TIMEOUT_MS,
212
+ fileStat = stat,
213
+ cleanupScheduler = scheduleCleanupOnNextTurn,
214
+ }) {
215
+ super();
216
+ if (!databasePath) throw new Error("databasePath is required");
217
+ this.databasePath = databasePath;
218
+ this.clock = clock;
219
+ this.cleanupClock = cleanupClock;
220
+ this.fileCleanupIntervalMs = fileCleanupIntervalMs;
221
+ this.fileCleanupConcurrency = fileCleanupConcurrency;
222
+ this.fileStatTimeoutMs = fileStatTimeoutMs;
223
+ this.fileStat = fileStat;
224
+ this.cleanupScheduler = cleanupScheduler;
225
+ if (!Number.isFinite(fileCleanupIntervalMs) || fileCleanupIntervalMs < 0) {
226
+ throw new Error("fileCleanupIntervalMs must be a non-negative number");
227
+ }
228
+ if (!Number.isInteger(fileCleanupConcurrency) || fileCleanupConcurrency < 1) {
229
+ throw new Error("fileCleanupConcurrency must be a positive integer");
230
+ }
231
+ if (!Number.isFinite(fileStatTimeoutMs) || fileStatTimeoutMs <= 0) {
232
+ throw new Error("fileStatTimeoutMs must be a positive number");
233
+ }
234
+ if (typeof fileStat !== "function") throw new Error("fileStat must be a function");
235
+ if (typeof cleanupScheduler !== "function") throw new Error("cleanupScheduler must be a function");
236
+ this.fileCleanupPromise = null;
237
+ this.fileStatOperations = new Set();
238
+ this.lastFileCleanupAt = Number.NEGATIVE_INFINITY;
239
+ this.db = null;
240
+ }
241
+
242
+ async open() {
243
+ await mkdir(path.dirname(this.databasePath), { recursive: true, mode: 0o700 });
244
+ const databaseFile = await openFile(this.databasePath, "a", 0o600);
245
+ await databaseFile.close();
246
+ await chmod(this.databasePath, 0o600);
247
+ this.db = new DatabaseSync(this.databasePath);
248
+ this.db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 3000; PRAGMA foreign_keys = ON;");
249
+ this.db.exec(`
250
+ CREATE TABLE IF NOT EXISTS metadata (
251
+ key TEXT PRIMARY KEY,
252
+ value INTEGER NOT NULL
253
+ ) STRICT;
254
+ INSERT OR IGNORE INTO metadata (key, value) VALUES ('revision', 0);
255
+
256
+ CREATE TABLE IF NOT EXISTS todos (
257
+ id TEXT PRIMARY KEY,
258
+ body TEXT NOT NULL,
259
+ sort_order INTEGER NOT NULL,
260
+ created_at INTEGER NOT NULL,
261
+ updated_at INTEGER NOT NULL,
262
+ completed_at INTEGER
263
+ ) STRICT;
264
+ DROP INDEX IF EXISTS todos_active_order;
265
+ CREATE INDEX IF NOT EXISTS todos_active_display_order
266
+ ON todos (completed_at, sort_order ASC, created_at DESC);
267
+ CREATE INDEX IF NOT EXISTS todos_completed_order
268
+ ON todos (completed_at DESC, updated_at DESC);
269
+
270
+ `);
271
+
272
+ const hasLegacyNotes = Boolean(this.db.prepare(`
273
+ SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'notes'
274
+ `).get());
275
+ const hasPrompts = Boolean(this.db.prepare(`
276
+ SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'prompts'
277
+ `).get());
278
+ if (hasLegacyNotes && hasPrompts) {
279
+ throw new Error("Both legacy notes and prompts tables exist; refusing an ambiguous Prompt migration");
280
+ }
281
+ if (hasLegacyNotes) {
282
+ this.db.exec("BEGIN IMMEDIATE");
283
+ try {
284
+ this.db.exec(`
285
+ ALTER TABLE notes RENAME TO prompts;
286
+ ALTER TABLE prompts RENAME COLUMN pinned TO starred;
287
+ DROP INDEX IF EXISTS notes_display_order;
288
+ `);
289
+ this.db.exec("COMMIT");
290
+ } catch (error) {
291
+ this.db.exec("ROLLBACK");
292
+ throw error;
293
+ }
294
+ }
295
+
296
+ this.db.exec(`
297
+ CREATE TABLE IF NOT EXISTS prompts (
298
+ id TEXT PRIMARY KEY,
299
+ title TEXT NOT NULL DEFAULT '',
300
+ body TEXT NOT NULL,
301
+ starred INTEGER NOT NULL DEFAULT 0 CHECK (starred IN (0, 1)),
302
+ created_at INTEGER NOT NULL,
303
+ updated_at INTEGER NOT NULL,
304
+ last_used_at INTEGER NOT NULL
305
+ ) STRICT;
306
+ CREATE INDEX IF NOT EXISTS prompts_display_order
307
+ ON prompts (starred DESC, last_used_at DESC, created_at DESC);
308
+
309
+ CREATE TABLE IF NOT EXISTS files (
310
+ id TEXT PRIMARY KEY,
311
+ canonical_path TEXT NOT NULL UNIQUE,
312
+ display_name TEXT NOT NULL,
313
+ mime_type TEXT NOT NULL,
314
+ size INTEGER,
315
+ added_at INTEGER NOT NULL,
316
+ last_called_at INTEGER NOT NULL
317
+ ) STRICT;
318
+ CREATE INDEX IF NOT EXISTS files_call_order
319
+ ON files (last_called_at DESC, added_at DESC);
320
+ `);
321
+ return this;
322
+ }
323
+
324
+ close() {
325
+ this.db?.close();
326
+ this.db = null;
327
+ }
328
+
329
+ ensureOpen() {
330
+ if (!this.db) throw new Error("CodexNotesProduct is not open");
331
+ }
332
+
333
+ get revision() {
334
+ this.ensureOpen();
335
+ return this.db.prepare("SELECT value FROM metadata WHERE key = 'revision'").get().value;
336
+ }
337
+
338
+ mutate(reason, operation) {
339
+ this.ensureOpen();
340
+ this.db.exec("BEGIN IMMEDIATE");
341
+ let result;
342
+ let revision;
343
+ try {
344
+ result = operation();
345
+ this.db.prepare("UPDATE metadata SET value = value + 1 WHERE key = 'revision'").run();
346
+ revision = this.revision;
347
+ this.db.exec("COMMIT");
348
+ } catch (error) {
349
+ this.db.exec("ROLLBACK");
350
+ throw error;
351
+ }
352
+ queueMicrotask(() => this.emit("change", {
353
+ revision,
354
+ reason,
355
+ surfaces: surfacesForReason(reason),
356
+ }));
357
+ return { result, revision };
358
+ }
359
+
360
+ async cleanupMissingFiles({ force = false } = {}) {
361
+ this.ensureOpen();
362
+ if (this.fileCleanupPromise) return this.fileCleanupPromise;
363
+ const now = this.cleanupClock();
364
+ if (!force && now - this.lastFileCleanupAt < this.fileCleanupIntervalMs) return [];
365
+ this.lastFileCleanupAt = now;
366
+ this.fileCleanupPromise = new Promise((resolve, reject) => {
367
+ try {
368
+ this.cleanupScheduler(() => {
369
+ this.performMissingFileCleanup().then(resolve, reject);
370
+ });
371
+ } catch (error) {
372
+ reject(error);
373
+ }
374
+ });
375
+ try {
376
+ return await this.fileCleanupPromise;
377
+ } finally {
378
+ this.fileCleanupPromise = null;
379
+ }
380
+ }
381
+
382
+ async performMissingFileCleanup() {
383
+ const rows = this.db.prepare("SELECT id, canonical_path FROM files").all();
384
+ const checks = await mapWithConcurrency(rows, this.fileCleanupConcurrency, async (row) => {
385
+ try {
386
+ const info = await statWithin(
387
+ this.fileStatOperations,
388
+ this.fileCleanupConcurrency,
389
+ this.fileStat,
390
+ row.canonical_path,
391
+ this.fileStatTimeoutMs,
392
+ );
393
+ if (!info) return null;
394
+ return info.isFile() ? null : row.id;
395
+ } catch (error) {
396
+ return isConfirmedMissing(error) ? row.id : null;
397
+ }
398
+ });
399
+ const missing = checks.filter(Boolean);
400
+ if (missing.length === 0) return [];
401
+ const stillPresent = this.db.prepare("SELECT 1 FROM files WHERE id = ?");
402
+ const removable = missing.filter((id) => stillPresent.get(id));
403
+ if (removable.length === 0) return [];
404
+ const { result } = this.mutate("files:missing-removed", () => {
405
+ const remove = this.db.prepare("DELETE FROM files WHERE id = ?");
406
+ let removed = 0;
407
+ for (const id of removable) removed += remove.run(id).changes;
408
+ return removed;
409
+ });
410
+ return result > 0 ? removable : [];
411
+ }
412
+
413
+ removeMissingFile(id) {
414
+ this.ensureOpen();
415
+ const exists = this.db.prepare("SELECT 1 FROM files WHERE id = ?").get(id);
416
+ if (!exists) return { revision: this.revision, removed: false };
417
+ const { result, revision } = this.mutate("file:missing-removed", () => (
418
+ this.db.prepare("DELETE FROM files WHERE id = ?").run(id).changes
419
+ ));
420
+ return { revision, removed: result > 0 };
421
+ }
422
+
423
+ async getState({ surface = "all", kind = null, id = null } = {}) {
424
+ void this.cleanupMissingFiles().catch(() => {});
425
+ this.ensureOpen();
426
+ if (!new Set(["all", "summary", "todo", "prompts", "files", "editor"]).has(surface)) {
427
+ throw new ProductError("surface is not supported", { code: "INVALID_SURFACE" });
428
+ }
429
+
430
+ const state = {
431
+ revision: this.revision,
432
+ todos: { active: [], completed: [] },
433
+ prompts: [],
434
+ files: [],
435
+ };
436
+
437
+ if (surface === "all" || surface === "summary" || surface === "todo") {
438
+ const todoLimit = surface === "summary" ? `LIMIT ${SUMMARY_ITEM_LIMIT}` : "";
439
+ state.todos.active = this.db.prepare(`
440
+ SELECT * FROM todos WHERE completed_at IS NULL
441
+ ORDER BY sort_order ASC, created_at DESC ${todoLimit}
442
+ `).all().map(mapTodo);
443
+ state.todos.completed = this.db.prepare(`
444
+ SELECT * FROM todos WHERE completed_at IS NOT NULL
445
+ ORDER BY completed_at DESC, updated_at DESC ${todoLimit}
446
+ `).all().map(mapTodo);
447
+ }
448
+ if (surface === "all" || surface === "summary" || surface === "prompts") {
449
+ const promptLimit = surface === "summary" ? `LIMIT ${SUMMARY_ITEM_LIMIT}` : "";
450
+ state.prompts = this.db.prepare(`
451
+ SELECT * FROM prompts ORDER BY starred DESC, last_used_at DESC, created_at DESC ${promptLimit}
452
+ `).all().map(mapPrompt);
453
+ }
454
+ if (surface === "all" || surface === "summary" || surface === "files") {
455
+ const fileLimit = surface === "summary" ? `LIMIT ${SUMMARY_ITEM_LIMIT}` : "";
456
+ state.files = this.db.prepare(`
457
+ SELECT * FROM files ORDER BY last_called_at DESC, added_at DESC ${fileLimit}
458
+ `).all().map(mapFile);
459
+ }
460
+ if (surface === "editor" && id) {
461
+ if (kind === "todo") {
462
+ const todo = this.db.prepare("SELECT * FROM todos WHERE id = ?").get(id);
463
+ if (todo) {
464
+ if (todo.completed_at == null) state.todos.active = [mapTodo(todo)];
465
+ else state.todos.completed = [mapTodo(todo)];
466
+ }
467
+ } else if (kind === "prompt") {
468
+ const prompt = this.db.prepare("SELECT * FROM prompts WHERE id = ?").get(id);
469
+ if (prompt) state.prompts = [mapPrompt(prompt)];
470
+ }
471
+ }
472
+
473
+ if (surface !== "all") state.surface = surface;
474
+ if (surface === "summary") {
475
+ state.counts = {
476
+ todos: {
477
+ active: this.db.prepare("SELECT COUNT(*) AS count FROM todos WHERE completed_at IS NULL").get().count,
478
+ completed: this.db.prepare("SELECT COUNT(*) AS count FROM todos WHERE completed_at IS NOT NULL").get().count,
479
+ },
480
+ prompts: this.db.prepare("SELECT COUNT(*) AS count FROM prompts").get().count,
481
+ files: this.db.prepare("SELECT COUNT(*) AS count FROM files").get().count,
482
+ };
483
+ }
484
+ return state;
485
+ }
486
+
487
+ getFile(id) {
488
+ this.ensureOpen();
489
+ const row = this.db.prepare("SELECT * FROM files WHERE id = ?").get(id);
490
+ if (!row) throw new ProductError("File was not found", { code: "NOT_FOUND", status: 404 });
491
+ return mapFile(row);
492
+ }
493
+
494
+ createTodo(input) {
495
+ const { body } = requiredObject(input);
496
+ const normalizedBody = requiredBody(body);
497
+ const now = this.clock();
498
+ const id = randomUUID();
499
+ const first = this.db.prepare(`
500
+ SELECT MIN(sort_order) AS first_order FROM todos WHERE completed_at IS NULL
501
+ `).get().first_order;
502
+ const order = first == null ? 0 : first - ORDER_STEP;
503
+ const { revision } = this.mutate("todo:created", () => {
504
+ this.db.prepare(`
505
+ INSERT INTO todos (id, body, sort_order, created_at, updated_at, completed_at)
506
+ VALUES (?, ?, ?, ?, ?, NULL)
507
+ `).run(id, normalizedBody, order, now, now);
508
+ });
509
+ return { item: mapTodo(this.db.prepare("SELECT * FROM todos WHERE id = ?").get(id)), revision };
510
+ }
511
+
512
+ updateTodo(id, input) {
513
+ const changes = requiredChanges(input, TODO_CHANGE_FIELDS);
514
+ const current = this.db.prepare("SELECT * FROM todos WHERE id = ?").get(id);
515
+ if (!current) throw new ProductError("Todo was not found", { code: "NOT_FOUND", status: 404 });
516
+ const now = this.clock();
517
+ const body = Object.hasOwn(changes, "body") ? requiredBody(changes.body) : current.body;
518
+ let completedAt = current.completed_at;
519
+ let order = current.sort_order;
520
+ if (Object.hasOwn(changes, "completed")) {
521
+ if (typeof changes.completed !== "boolean") {
522
+ throw new ProductError("completed must be a boolean", { code: "INVALID_COMPLETED" });
523
+ }
524
+ if (changes.completed && completedAt == null) completedAt = now;
525
+ if (!changes.completed && completedAt != null) {
526
+ completedAt = null;
527
+ const first = this.db.prepare(`
528
+ SELECT MIN(sort_order) AS first_order FROM todos WHERE completed_at IS NULL
529
+ `).get().first_order;
530
+ order = first == null ? 0 : first - ORDER_STEP;
531
+ }
532
+ }
533
+ const { revision } = this.mutate("todo:updated", () => {
534
+ this.db.prepare(`
535
+ UPDATE todos SET body = ?, sort_order = ?, updated_at = ?, completed_at = ? WHERE id = ?
536
+ `).run(body, order, now, completedAt, id);
537
+ });
538
+ return { item: mapTodo(this.db.prepare("SELECT * FROM todos WHERE id = ?").get(id)), revision };
539
+ }
540
+
541
+ reorderTodos(ids) {
542
+ if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string")) {
543
+ throw new ProductError("ids must be an array of Todo ids", { code: "INVALID_ORDER" });
544
+ }
545
+ const activeIds = this.db.prepare(`
546
+ SELECT id FROM todos WHERE completed_at IS NULL ORDER BY sort_order ASC, created_at DESC
547
+ `).all().map((row) => row.id);
548
+ const requestedIds = new Set(ids);
549
+ if (ids.length !== activeIds.length || requestedIds.size !== ids.length
550
+ || activeIds.some((id) => !requestedIds.has(id))) {
551
+ throw new ProductError("Order must contain every active Todo exactly once", {
552
+ code: "INVALID_ORDER",
553
+ });
554
+ }
555
+ const now = this.clock();
556
+ const { revision } = this.mutate("todo:reordered", () => {
557
+ const update = this.db.prepare("UPDATE todos SET sort_order = ?, updated_at = ? WHERE id = ?");
558
+ ids.forEach((id, index) => update.run(index * ORDER_STEP, now, id));
559
+ });
560
+ return { revision };
561
+ }
562
+
563
+ deleteTodo(id) {
564
+ if (!this.db.prepare("SELECT 1 FROM todos WHERE id = ?").get(id)) {
565
+ throw new ProductError("Todo was not found", { code: "NOT_FOUND", status: 404 });
566
+ }
567
+ const { result, revision } = this.mutate("todo:deleted", () => (
568
+ this.db.prepare("DELETE FROM todos WHERE id = ?").run(id).changes
569
+ ));
570
+ if (!result) throw new Error("Todo disappeared during single-writer deletion");
571
+ return { revision };
572
+ }
573
+
574
+ clearCompletedTodos() {
575
+ const completed = this.db.prepare("SELECT COUNT(*) AS count FROM todos WHERE completed_at IS NOT NULL").get().count;
576
+ if (completed === 0) return { revision: this.revision, removed: 0 };
577
+ const { result, revision } = this.mutate("todo:completed-cleared", () => (
578
+ this.db.prepare("DELETE FROM todos WHERE completed_at IS NOT NULL").run().changes
579
+ ));
580
+ return { revision, removed: result };
581
+ }
582
+
583
+ createPrompt(input) {
584
+ const { title = "", body } = requiredObject(input);
585
+ const normalizedBody = requiredBody(body);
586
+ const normalizedTitle = optionalTitle(title);
587
+ const now = this.clock();
588
+ const id = randomUUID();
589
+ const { revision } = this.mutate("prompt:created", () => {
590
+ this.db.prepare(`
591
+ INSERT INTO prompts (id, title, body, starred, created_at, updated_at, last_used_at)
592
+ VALUES (?, ?, ?, 0, ?, ?, ?)
593
+ `).run(id, normalizedTitle, normalizedBody, now, now, now);
594
+ });
595
+ return { item: mapPrompt(this.db.prepare("SELECT * FROM prompts WHERE id = ?").get(id)), revision };
596
+ }
597
+
598
+ updatePrompt(id, input) {
599
+ const changes = requiredChanges(input, PROMPT_CHANGE_FIELDS);
600
+ const current = this.db.prepare("SELECT * FROM prompts WHERE id = ?").get(id);
601
+ if (!current) throw new ProductError("Prompt was not found", { code: "NOT_FOUND", status: 404 });
602
+ const editing = Object.hasOwn(changes, "title") || Object.hasOwn(changes, "body");
603
+ const title = Object.hasOwn(changes, "title") ? optionalTitle(changes.title) : current.title;
604
+ const body = Object.hasOwn(changes, "body") ? requiredBody(changes.body) : current.body;
605
+ let starred = Boolean(current.starred);
606
+ if (Object.hasOwn(changes, "starred")) {
607
+ if (typeof changes.starred !== "boolean") {
608
+ throw new ProductError("starred must be a boolean", { code: "INVALID_STAR" });
609
+ }
610
+ starred = changes.starred;
611
+ }
612
+ const now = this.clock();
613
+ const lastUsedAt = editing ? now : current.last_used_at;
614
+ const { revision } = this.mutate(editing ? "prompt:edited" : "prompt:starred", () => {
615
+ this.db.prepare(`
616
+ UPDATE prompts
617
+ SET title = ?, body = ?, starred = ?, updated_at = ?, last_used_at = ?
618
+ WHERE id = ?
619
+ `).run(title, body, Number(starred), now, lastUsedAt, id);
620
+ });
621
+ return { item: mapPrompt(this.db.prepare("SELECT * FROM prompts WHERE id = ?").get(id)), revision };
622
+ }
623
+
624
+ markPromptUsed(id, input) {
625
+ const { action, success } = requiredObject(input);
626
+ if (!new Set(["copy", "insert"]).has(action) || success !== true) {
627
+ throw new ProductError("A successful copy or insert action is required", {
628
+ code: "INVALID_USE",
629
+ });
630
+ }
631
+ if (!this.db.prepare("SELECT 1 FROM prompts WHERE id = ?").get(id)) {
632
+ throw new ProductError("Prompt was not found", { code: "NOT_FOUND", status: 404 });
633
+ }
634
+ const now = this.clock();
635
+ const { revision } = this.mutate(`prompt:${action}`, () => {
636
+ this.db.prepare("UPDATE prompts SET last_used_at = ? WHERE id = ?").run(now, id);
637
+ });
638
+ return { revision };
639
+ }
640
+
641
+ deletePrompt(id) {
642
+ if (!this.db.prepare("SELECT 1 FROM prompts WHERE id = ?").get(id)) {
643
+ throw new ProductError("Prompt was not found", { code: "NOT_FOUND", status: 404 });
644
+ }
645
+ const { result, revision } = this.mutate("prompt:deleted", () => (
646
+ this.db.prepare("DELETE FROM prompts WHERE id = ?").run(id).changes
647
+ ));
648
+ if (!result) throw new Error("Prompt disappeared during single-writer deletion");
649
+ return { revision };
650
+ }
651
+
652
+ async addFiles(input) {
653
+ const { paths } = requiredObject(input);
654
+ if (!Array.isArray(paths) || paths.length === 0 || paths.some((entry) => typeof entry !== "string")) {
655
+ throw new ProductError("paths must contain one or more absolute file paths", {
656
+ code: "INVALID_PATHS",
657
+ });
658
+ }
659
+ const inspected = await mapWithConcurrency(paths, DEFAULT_FILE_ADD_CONCURRENCY, async (requestedPath) => {
660
+ try {
661
+ if (!path.isAbsolute(requestedPath)) {
662
+ throw new ProductError("Only absolute paths can be pinned", { code: "PATH_NOT_ABSOLUTE" });
663
+ }
664
+ const canonicalPath = await realpath(requestedPath);
665
+ const info = await stat(canonicalPath);
666
+ if (!info.isFile()) {
667
+ throw new ProductError("Folders cannot be pinned; choose regular files", {
668
+ code: "DIRECTORY_NOT_ALLOWED",
669
+ });
670
+ }
671
+ return { accepted: { canonicalPath, size: info.size } };
672
+ } catch (error) {
673
+ return { rejected: {
674
+ path: requestedPath,
675
+ code: error instanceof ProductError ? error.code : "FILE_UNAVAILABLE",
676
+ message: error instanceof ProductError ? error.message : "The file is unavailable",
677
+ } };
678
+ }
679
+ });
680
+ const accepted = new Map();
681
+ const rejected = [];
682
+ for (const result of inspected) {
683
+ if (result.accepted) accepted.set(result.accepted.canonicalPath, result.accepted);
684
+ else rejected.push(result.rejected);
685
+ }
686
+
687
+ const now = this.clock();
688
+ const addedIds = [];
689
+ const existingIds = [];
690
+ let revision = this.revision;
691
+ if (accepted.size > 0) {
692
+ const newEntries = [];
693
+ for (const entry of accepted.values()) {
694
+ const existing = this.db.prepare("SELECT id FROM files WHERE canonical_path = ?").get(entry.canonicalPath);
695
+ if (existing) existingIds.push(existing.id);
696
+ else newEntries.push({ ...entry, id: randomUUID() });
697
+ }
698
+ if (newEntries.length > 0) {
699
+ ({ revision } = this.mutate("file:added", () => {
700
+ const insert = this.db.prepare(`
701
+ INSERT INTO files
702
+ (id, canonical_path, display_name, mime_type, size, added_at, last_called_at)
703
+ VALUES (?, ?, ?, ?, ?, ?, ?)
704
+ `);
705
+ for (const entry of newEntries) {
706
+ insert.run(
707
+ entry.id,
708
+ entry.canonicalPath,
709
+ path.basename(entry.canonicalPath),
710
+ mimeTypeFor(entry.canonicalPath),
711
+ entry.size,
712
+ now,
713
+ now,
714
+ );
715
+ addedIds.push(entry.id);
716
+ }
717
+ }));
718
+ }
719
+ }
720
+
721
+ const select = this.db.prepare("SELECT * FROM files WHERE id = ?");
722
+ return {
723
+ added: addedIds.map((id) => mapFile(select.get(id))),
724
+ existing: existingIds.map((id) => mapFile(select.get(id))),
725
+ rejected,
726
+ revision,
727
+ };
728
+ }
729
+
730
+ async markFileUsed(id, input) {
731
+ const { action, success } = requiredObject(input);
732
+ if (!new Set(["attach", "insert-path"]).has(action) || success !== true) {
733
+ throw new ProductError("A successful attachment or path insertion is required", {
734
+ code: "INVALID_USE",
735
+ });
736
+ }
737
+ const current = this.db.prepare("SELECT * FROM files WHERE id = ?").get(id);
738
+ if (!current) throw new ProductError("File was not found", { code: "NOT_FOUND", status: 404 });
739
+ try {
740
+ const info = await stat(current.canonical_path);
741
+ if (!info.isFile()) throw Object.assign(new Error("Not a regular file"), { code: "ENOENT" });
742
+ } catch (error) {
743
+ if (isConfirmedMissing(error)) {
744
+ this.mutate("file:missing-removed", () => {
745
+ this.db.prepare("DELETE FROM files WHERE id = ?").run(id);
746
+ });
747
+ throw new ProductError("The source file no longer exists and was removed", {
748
+ code: "FILE_MISSING",
749
+ status: 410,
750
+ });
751
+ }
752
+ throw error;
753
+ }
754
+ const now = this.clock();
755
+ const { revision } = this.mutate(`file:${action}`, () => {
756
+ this.db.prepare("UPDATE files SET last_called_at = ? WHERE id = ?").run(now, id);
757
+ });
758
+ return { revision };
759
+ }
760
+
761
+ deleteFile(id) {
762
+ if (!this.db.prepare("SELECT 1 FROM files WHERE id = ?").get(id)) {
763
+ throw new ProductError("File was not found", { code: "NOT_FOUND", status: 404 });
764
+ }
765
+ const { result, revision } = this.mutate("file:deleted", () => (
766
+ this.db.prepare("DELETE FROM files WHERE id = ?").run(id).changes
767
+ ));
768
+ if (!result) throw new Error("File disappeared during single-writer deletion");
769
+ return { revision };
770
+ }
771
+ }