minecodex 1.2.1 → 2.0.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/README.md +9 -3
- package/features/images/README.md +16 -98
- package/features/images/codex-feature.json +6 -14
- package/features/images/src/http-server.mjs +15 -5
- package/features/images/src/image-library.mjs +37 -9
- package/features/images/src/library-prompt-page.mjs +239 -0
- package/features/images/src/prompt-index.mjs +27 -24
- package/features/images/src/prompt-source.mjs +100 -0
- package/features/images/web/app.js +180 -67
- package/features/images/web/i18n.mjs +5 -0
- package/features/images/web/index.html +29 -24
- package/features/images/web/styles.css +169 -139
- package/features/model-slider/README.md +13 -1
- package/features/model-slider/THIRD_PARTY_NOTICES.md +1 -1
- package/features/notes/DESIGN.md +66 -0
- package/features/notes/README.md +18 -99
- package/features/notes/assets/icons/book-open-nav.svg +15 -0
- package/features/notes/assets/icons/book-open.svg +16 -0
- package/features/notes/assets/native-emojis.json +1 -0
- package/features/notes/assets/native-prompt-icons.json +28 -0
- package/features/notes/codex-feature.json +12 -70
- package/features/notes/src/main.mjs +2 -2
- package/features/notes/src/product.mjs +287 -613
- package/features/notes/src/prompt-schema.mjs +117 -0
- package/features/notes/src/quick-prompts-page.mjs +1559 -0
- package/features/notes/src/quick-prompts-server.mjs +167 -0
- package/features/session-status/DESIGN.md +10 -2
- package/features/session-status/README.md +13 -3
- package/features/session-status/src/session-status.mjs +329 -24
- package/features/sidebar-focus/DESIGN.md +16 -0
- package/features/sidebar-focus/codex-feature.json +20 -0
- package/features/sidebar-focus/src/sidebar-focus.mjs +134 -0
- package/features/sidebar-focus/vendor/lucide-LICENSE +43 -0
- package/features/unread-inbox/DESIGN.md +28 -0
- package/features/unread-inbox/codex-feature.json +20 -0
- package/features/unread-inbox/src/unread-inbox.mjs +360 -0
- package/features/usage-meter/DESIGN.md +49 -0
- package/features/usage-meter/README.md +18 -0
- package/features/usage-meter/codex-feature.json +13 -0
- package/features/usage-meter/src/usage-meter.mjs +200 -0
- package/package.json +21 -6
- package/packages/cli/src/config.mjs +4 -1
- package/packages/cli/src/control-server.mjs +2 -2
- package/packages/runtime-host/src/cloud-images.mjs +123 -0
- package/packages/runtime-host/src/codex-cdp.mjs +42 -8
- package/packages/runtime-host/src/codex-design-contract.mjs +10 -0
- package/packages/runtime-host/src/codex-injection.mjs +364 -116
- package/packages/runtime-host/src/codex-runtime.mjs +105 -28
- package/packages/runtime-host/src/feature-registry.mjs +3 -0
- package/packages/runtime-host/src/session-status-reader.mjs +65 -24
- package/features/notes/assets/icons/circle.svg +0 -15
- package/features/notes/assets/icons/ellipsis.svg +0 -17
- package/features/notes/assets/icons/external-link.svg +0 -17
- package/features/notes/assets/icons/list-todo.svg +0 -19
- package/features/notes/assets/icons/message-square-quote.svg +0 -17
- package/features/notes/assets/icons/paperclip.svg +0 -15
- package/features/notes/assets/icons/star-off.svg +0 -17
- package/features/notes/assets/icons/star.svg +0 -15
- package/features/notes/assets/icons/sticky-note.svg +0 -16
- package/features/notes/assets/icons/trash-2.svg +0 -19
- package/features/notes/src/http-server.mjs +0 -580
- package/features/notes/web/app.js +0 -1202
- package/features/notes/web/codex-artifact-document.svg +0 -1
- package/features/notes/web/codex-attachment.svg +0 -1
- package/features/notes/web/codex-build.svg +0 -1
- package/features/notes/web/codex-code.svg +0 -1
- package/features/notes/web/codex-cplusplus.svg +0 -1
- package/features/notes/web/codex-css.svg +0 -1
- package/features/notes/web/codex-document.svg +0 -1
- package/features/notes/web/codex-file.svg +0 -1
- package/features/notes/web/codex-folder.svg +0 -1
- package/features/notes/web/codex-hashes.svg +0 -1
- package/features/notes/web/codex-html.svg +0 -1
- package/features/notes/web/codex-image.svg +0 -1
- package/features/notes/web/codex-java.svg +0 -1
- package/features/notes/web/codex-javascript.svg +0 -1
- package/features/notes/web/codex-json.svg +0 -1
- package/features/notes/web/codex-notebook.svg +0 -1
- package/features/notes/web/codex-pdf.svg +0 -1
- package/features/notes/web/codex-pencil.svg +0 -3
- package/features/notes/web/codex-php.svg +0 -1
- package/features/notes/web/codex-plus.svg +0 -3
- package/features/notes/web/codex-presentation.svg +0 -1
- package/features/notes/web/codex-python.svg +0 -1
- package/features/notes/web/codex-react.svg +0 -1
- package/features/notes/web/codex-rust.svg +0 -1
- package/features/notes/web/codex-shell.svg +0 -1
- package/features/notes/web/codex-skill.svg +0 -1
- package/features/notes/web/codex-spreadsheet.svg +0 -1
- package/features/notes/web/codex-task.svg +0 -3
- package/features/notes/web/codex-terminal.svg +0 -1
- package/features/notes/web/codex-toml.svg +0 -1
- package/features/notes/web/codex-typescript.svg +0 -1
- package/features/notes/web/codex-view-all.svg +0 -3
- package/features/notes/web/file-icons.mjs +0 -68
- package/features/notes/web/i18n.mjs +0 -237
- package/features/notes/web/index.html +0 -15
- package/features/notes/web/styles.css +0 -1155
- package/features/notes/web/todo-drag.mjs +0 -18
|
@@ -1,54 +1,24 @@
|
|
|
1
|
+
import { migratePromptSchema } from "./prompt-schema.mjs";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import { chmod, mkdir, open as openFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { chmod, mkdir, open as openFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { DatabaseSync } from "node:sqlite";
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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"],
|
|
6
|
+
import nativePromptIcons from "../assets/native-prompt-icons.json" with { type: "json" };
|
|
7
|
+
const PROMPT_CATEGORY_ICONS = new Set(Object.keys(nativePromptIcons));
|
|
8
|
+
|
|
9
|
+
const PROMPT_CHANGE_FIELDS = new Set([
|
|
10
|
+
"title",
|
|
11
|
+
"body",
|
|
12
|
+
"categoryId",
|
|
13
|
+
"emoji",
|
|
14
|
+
"sortOrder",
|
|
48
15
|
]);
|
|
49
16
|
|
|
50
17
|
export class ProductError extends Error {
|
|
51
|
-
constructor(
|
|
18
|
+
constructor(
|
|
19
|
+
message,
|
|
20
|
+
{ code = "INVALID_REQUEST", status = 400, details = null } = {},
|
|
21
|
+
) {
|
|
52
22
|
super(message);
|
|
53
23
|
this.name = "ProductError";
|
|
54
24
|
this.code = code;
|
|
@@ -59,7 +29,9 @@ export class ProductError extends Error {
|
|
|
59
29
|
|
|
60
30
|
function requiredBody(value, field = "body") {
|
|
61
31
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
62
|
-
throw new ProductError(`${field} must contain text`, {
|
|
32
|
+
throw new ProductError(`${field} must contain text`, {
|
|
33
|
+
code: "EMPTY_BODY",
|
|
34
|
+
});
|
|
63
35
|
}
|
|
64
36
|
return value;
|
|
65
37
|
}
|
|
@@ -74,7 +46,9 @@ function optionalTitle(value) {
|
|
|
74
46
|
|
|
75
47
|
function requiredObject(value, field = "request body") {
|
|
76
48
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
77
|
-
throw new ProductError(`${field} must be a JSON object`, {
|
|
49
|
+
throw new ProductError(`${field} must be a JSON object`, {
|
|
50
|
+
code: "INVALID_BODY",
|
|
51
|
+
});
|
|
78
52
|
}
|
|
79
53
|
return value;
|
|
80
54
|
}
|
|
@@ -82,7 +56,10 @@ function requiredObject(value, field = "request body") {
|
|
|
82
56
|
function requiredChanges(value, allowedFields) {
|
|
83
57
|
const changes = requiredObject(value, "changes");
|
|
84
58
|
const fields = Object.keys(changes);
|
|
85
|
-
if (
|
|
59
|
+
if (
|
|
60
|
+
fields.length === 0 ||
|
|
61
|
+
fields.some((field) => !allowedFields.has(field))
|
|
62
|
+
) {
|
|
86
63
|
throw new ProductError("changes must contain only supported fields", {
|
|
87
64
|
code: "INVALID_CHANGES",
|
|
88
65
|
details: { allowedFields: [...allowedFields] },
|
|
@@ -91,61 +68,6 @@ function requiredChanges(value, allowedFields) {
|
|
|
91
68
|
return changes;
|
|
92
69
|
}
|
|
93
70
|
|
|
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
71
|
function promptSummary(title, body) {
|
|
150
72
|
if (title.trim()) return title.trim();
|
|
151
73
|
let start = 0;
|
|
@@ -166,159 +88,187 @@ function mapPrompt(row) {
|
|
|
166
88
|
title: row.title,
|
|
167
89
|
body: row.body,
|
|
168
90
|
summary: promptSummary(row.title, row.body),
|
|
169
|
-
starred: Boolean(row.starred),
|
|
170
91
|
createdAt: row.created_at,
|
|
171
92
|
updatedAt: row.updated_at,
|
|
172
|
-
|
|
93
|
+
categoryId: row.category_id,
|
|
94
|
+
emoji: row.emoji,
|
|
95
|
+
sortOrder: row.sort_order,
|
|
96
|
+
useCount: row.use_count,
|
|
97
|
+
calledAt: row.called_at,
|
|
173
98
|
};
|
|
174
99
|
}
|
|
175
100
|
|
|
176
|
-
|
|
177
|
-
|
|
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();
|
|
101
|
+
export class CodexNotesProduct {
|
|
102
|
+
constructor({ databasePath, clock = () => Date.now() }) {
|
|
216
103
|
if (!databasePath) throw new Error("databasePath is required");
|
|
217
104
|
this.databasePath = databasePath;
|
|
218
105
|
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
106
|
this.db = null;
|
|
240
107
|
}
|
|
241
108
|
|
|
242
109
|
async open() {
|
|
243
|
-
await mkdir(path.dirname(this.databasePath), {
|
|
110
|
+
await mkdir(path.dirname(this.databasePath), {
|
|
111
|
+
recursive: true,
|
|
112
|
+
mode: 0o700,
|
|
113
|
+
});
|
|
244
114
|
const databaseFile = await openFile(this.databasePath, "a", 0o600);
|
|
245
115
|
await databaseFile.close();
|
|
246
116
|
await chmod(this.databasePath, 0o600);
|
|
247
117
|
this.db = new DatabaseSync(this.databasePath);
|
|
248
|
-
this.db.exec(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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);
|
|
118
|
+
this.db.exec(
|
|
119
|
+
"PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 3000; PRAGMA foreign_keys = ON;",
|
|
120
|
+
);
|
|
121
|
+
try {
|
|
122
|
+
migratePromptSchema(this.db);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
this.close();
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
return this;
|
|
128
|
+
}
|
|
269
129
|
|
|
270
|
-
|
|
130
|
+
listPromptCategories() {
|
|
131
|
+
return this.db
|
|
132
|
+
.prepare(
|
|
133
|
+
"SELECT id, name, icon, color, sort_order AS sortOrder FROM prompt_categories ORDER BY sort_order, id",
|
|
134
|
+
)
|
|
135
|
+
.all();
|
|
136
|
+
}
|
|
271
137
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
138
|
+
promptMetadata(input, current = {}) {
|
|
139
|
+
const categoryId = Object.hasOwn(input, "categoryId")
|
|
140
|
+
? input.categoryId
|
|
141
|
+
: (current.category_id ?? null);
|
|
142
|
+
if (
|
|
143
|
+
categoryId !== null &&
|
|
144
|
+
(typeof categoryId !== "string" ||
|
|
145
|
+
!this.db
|
|
146
|
+
.prepare("SELECT 1 FROM prompt_categories WHERE id = ?")
|
|
147
|
+
.get(categoryId))
|
|
148
|
+
) {
|
|
149
|
+
throw new ProductError("Category was not found");
|
|
280
150
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
151
|
+
const emoji = Object.hasOwn(input, "emoji")
|
|
152
|
+
? input.emoji
|
|
153
|
+
: (current.emoji ?? "");
|
|
154
|
+
if (
|
|
155
|
+
typeof emoji !== "string" ||
|
|
156
|
+
(emoji !== "" &&
|
|
157
|
+
([
|
|
158
|
+
...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(
|
|
159
|
+
emoji,
|
|
160
|
+
),
|
|
161
|
+
].length !== 1 ||
|
|
162
|
+
!/\p{Extended_Pictographic}|\p{Regional_Indicator}|\u20e3/u.test(
|
|
163
|
+
emoji,
|
|
164
|
+
)))
|
|
165
|
+
) {
|
|
166
|
+
throw new ProductError("Choose one emoji or leave it empty");
|
|
294
167
|
}
|
|
168
|
+
const sortOrder = Object.hasOwn(input, "sortOrder")
|
|
169
|
+
? input.sortOrder
|
|
170
|
+
: (current.sort_order ??
|
|
171
|
+
this.db
|
|
172
|
+
.prepare(
|
|
173
|
+
"SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM prompts",
|
|
174
|
+
)
|
|
175
|
+
.get().next);
|
|
176
|
+
if (!Number.isSafeInteger(sortOrder) || sortOrder < 0)
|
|
177
|
+
throw new ProductError("Invalid sort order");
|
|
178
|
+
return { categoryId, emoji, sortOrder };
|
|
179
|
+
}
|
|
295
180
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
181
|
+
savePromptCategory(id, input) {
|
|
182
|
+
requiredObject(input);
|
|
183
|
+
const current = id
|
|
184
|
+
? this.db.prepare("SELECT * FROM prompt_categories WHERE id = ?").get(id)
|
|
185
|
+
: null;
|
|
186
|
+
if (id && !current)
|
|
187
|
+
throw new ProductError("Category was not found", { status: 404 });
|
|
188
|
+
const name = input.name ?? current?.name;
|
|
189
|
+
const icon = input.icon ?? current?.icon ?? "book";
|
|
190
|
+
const color = input.color ?? current?.color ?? "black";
|
|
191
|
+
const sortOrder =
|
|
192
|
+
input.sortOrder ??
|
|
193
|
+
current?.sort_order ??
|
|
194
|
+
this.listPromptCategories().length;
|
|
195
|
+
if (
|
|
196
|
+
typeof name !== "string" ||
|
|
197
|
+
!name.trim() ||
|
|
198
|
+
name.length > 60 ||
|
|
199
|
+
!PROMPT_CATEGORY_ICONS.has(icon) ||
|
|
200
|
+
![
|
|
201
|
+
"black",
|
|
202
|
+
"blue",
|
|
203
|
+
"green",
|
|
204
|
+
"orange",
|
|
205
|
+
"pink",
|
|
206
|
+
"purple",
|
|
207
|
+
"red",
|
|
208
|
+
"yellow",
|
|
209
|
+
].includes(color) ||
|
|
210
|
+
!Number.isSafeInteger(sortOrder) ||
|
|
211
|
+
sortOrder < 0
|
|
212
|
+
)
|
|
213
|
+
throw new ProductError("Invalid category");
|
|
214
|
+
const categoryId = id ?? randomUUID();
|
|
215
|
+
const { revision } = this.mutate(() => {
|
|
216
|
+
this.db
|
|
217
|
+
.prepare(
|
|
218
|
+
`INSERT INTO prompt_categories VALUES (?, ?, ?, ?, ?)
|
|
219
|
+
ON CONFLICT(id) DO UPDATE SET name=excluded.name, icon=excluded.icon, color=excluded.color, sort_order=excluded.sort_order`,
|
|
220
|
+
)
|
|
221
|
+
.run(categoryId, name.trim(), icon, color, sortOrder);
|
|
222
|
+
});
|
|
223
|
+
return {
|
|
224
|
+
revision,
|
|
225
|
+
item: this.listPromptCategories().find((item) => item.id === categoryId),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
308
228
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
229
|
+
deletePromptCategory(id) {
|
|
230
|
+
const { revision } = this.mutate(() => {
|
|
231
|
+
this.db.prepare("DELETE FROM prompt_categories WHERE id = ?").run(id);
|
|
232
|
+
});
|
|
233
|
+
return { revision };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
reorderPrompts(input) {
|
|
237
|
+
const {
|
|
238
|
+
ids,
|
|
239
|
+
categoryId = null,
|
|
240
|
+
categories = false,
|
|
241
|
+
} = requiredObject(input);
|
|
242
|
+
if (
|
|
243
|
+
typeof categories !== "boolean" ||
|
|
244
|
+
(categoryId !== null && typeof categoryId !== "string")
|
|
245
|
+
)
|
|
246
|
+
throw new ProductError("Invalid order");
|
|
247
|
+
if (
|
|
248
|
+
!Array.isArray(ids) ||
|
|
249
|
+
ids.some((id) => typeof id !== "string") ||
|
|
250
|
+
new Set(ids).size !== ids.length
|
|
251
|
+
) {
|
|
252
|
+
throw new ProductError("Invalid order");
|
|
253
|
+
}
|
|
254
|
+
const expected = categories
|
|
255
|
+
? this.listPromptCategories().map((item) => item.id)
|
|
256
|
+
: this.db
|
|
257
|
+
.prepare("SELECT id FROM prompts WHERE category_id IS ?")
|
|
258
|
+
.all(categoryId)
|
|
259
|
+
.map((row) => row.id);
|
|
260
|
+
if (
|
|
261
|
+
expected.length !== ids.length ||
|
|
262
|
+
expected.some((id) => !ids.includes(id))
|
|
263
|
+
)
|
|
264
|
+
throw new ProductError("Items changed; refresh and try again");
|
|
265
|
+
const { revision } = this.mutate(() => {
|
|
266
|
+
const statement = this.db.prepare(
|
|
267
|
+
`UPDATE ${categories ? "prompt_categories" : "prompts"} SET sort_order = ? WHERE id = ?`,
|
|
268
|
+
);
|
|
269
|
+
ids.forEach((id, index) => statement.run(index, id));
|
|
270
|
+
});
|
|
271
|
+
return { revision };
|
|
322
272
|
}
|
|
323
273
|
|
|
324
274
|
close() {
|
|
@@ -332,293 +282,120 @@ export class CodexNotesProduct extends EventEmitter {
|
|
|
332
282
|
|
|
333
283
|
get revision() {
|
|
334
284
|
this.ensureOpen();
|
|
335
|
-
return this.db
|
|
285
|
+
return this.db
|
|
286
|
+
.prepare("SELECT value FROM metadata WHERE key = 'revision'")
|
|
287
|
+
.get().value;
|
|
336
288
|
}
|
|
337
289
|
|
|
338
|
-
mutate(
|
|
290
|
+
mutate(operation) {
|
|
339
291
|
this.ensureOpen();
|
|
340
292
|
this.db.exec("BEGIN IMMEDIATE");
|
|
341
293
|
let result;
|
|
342
294
|
let revision;
|
|
343
295
|
try {
|
|
344
296
|
result = operation();
|
|
345
|
-
this.db
|
|
297
|
+
this.db
|
|
298
|
+
.prepare("UPDATE metadata SET value = value + 1 WHERE key = 'revision'")
|
|
299
|
+
.run();
|
|
346
300
|
revision = this.revision;
|
|
347
301
|
this.db.exec("COMMIT");
|
|
348
302
|
} catch (error) {
|
|
349
303
|
this.db.exec("ROLLBACK");
|
|
350
304
|
throw error;
|
|
351
305
|
}
|
|
352
|
-
queueMicrotask(() => this.emit("change", {
|
|
353
|
-
revision,
|
|
354
|
-
reason,
|
|
355
|
-
surfaces: surfacesForReason(reason),
|
|
356
|
-
}));
|
|
357
306
|
return { result, revision };
|
|
358
307
|
}
|
|
359
308
|
|
|
360
|
-
|
|
309
|
+
getState() {
|
|
361
310
|
this.ensureOpen();
|
|
362
|
-
|
|
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 = {
|
|
311
|
+
return {
|
|
431
312
|
revision: this.revision,
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
313
|
+
prompts: this.db
|
|
314
|
+
.prepare(
|
|
315
|
+
"SELECT * FROM prompts ORDER BY sort_order, created_at DESC, id",
|
|
316
|
+
)
|
|
317
|
+
.all()
|
|
318
|
+
.map(mapPrompt),
|
|
319
|
+
categories: this.listPromptCategories(),
|
|
435
320
|
};
|
|
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
321
|
}
|
|
582
322
|
|
|
583
323
|
createPrompt(input) {
|
|
584
324
|
const { title = "", body } = requiredObject(input);
|
|
585
325
|
const normalizedBody = requiredBody(body);
|
|
586
326
|
const normalizedTitle = optionalTitle(title);
|
|
327
|
+
const metadata = this.promptMetadata(input);
|
|
587
328
|
const now = this.clock();
|
|
588
329
|
const id = randomUUID();
|
|
589
|
-
const { revision } = this.mutate(
|
|
590
|
-
this.db
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
330
|
+
const { revision } = this.mutate(() => {
|
|
331
|
+
this.db
|
|
332
|
+
.prepare(
|
|
333
|
+
`
|
|
334
|
+
INSERT INTO prompts (id, title, body, starred, created_at, updated_at, last_used_at, category_id, emoji, sort_order)
|
|
335
|
+
VALUES (?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
|
|
336
|
+
`,
|
|
337
|
+
)
|
|
338
|
+
.run(
|
|
339
|
+
id,
|
|
340
|
+
normalizedTitle,
|
|
341
|
+
normalizedBody,
|
|
342
|
+
now,
|
|
343
|
+
now,
|
|
344
|
+
now,
|
|
345
|
+
metadata.categoryId,
|
|
346
|
+
metadata.emoji,
|
|
347
|
+
metadata.sortOrder,
|
|
348
|
+
);
|
|
594
349
|
});
|
|
595
|
-
return {
|
|
350
|
+
return {
|
|
351
|
+
item: mapPrompt(
|
|
352
|
+
this.db.prepare("SELECT * FROM prompts WHERE id = ?").get(id),
|
|
353
|
+
),
|
|
354
|
+
revision,
|
|
355
|
+
};
|
|
596
356
|
}
|
|
597
357
|
|
|
598
358
|
updatePrompt(id, input) {
|
|
599
359
|
const changes = requiredChanges(input, PROMPT_CHANGE_FIELDS);
|
|
600
|
-
const current = this.db
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
360
|
+
const current = this.db
|
|
361
|
+
.prepare("SELECT * FROM prompts WHERE id = ?")
|
|
362
|
+
.get(id);
|
|
363
|
+
if (!current)
|
|
364
|
+
throw new ProductError("Prompt was not found", {
|
|
365
|
+
code: "NOT_FOUND",
|
|
366
|
+
status: 404,
|
|
367
|
+
});
|
|
368
|
+
const metadata = this.promptMetadata(changes, current);
|
|
369
|
+
const title = Object.hasOwn(changes, "title")
|
|
370
|
+
? optionalTitle(changes.title)
|
|
371
|
+
: current.title;
|
|
372
|
+
const body = Object.hasOwn(changes, "body")
|
|
373
|
+
? requiredBody(changes.body)
|
|
374
|
+
: current.body;
|
|
375
|
+
const { revision } = this.mutate(() => {
|
|
376
|
+
this.db
|
|
377
|
+
.prepare(
|
|
378
|
+
`
|
|
379
|
+
UPDATE prompts SET title = ?, body = ?, updated_at = ?, category_id = ?, emoji = ?, sort_order = ?
|
|
618
380
|
WHERE id = ?
|
|
619
|
-
|
|
381
|
+
`,
|
|
382
|
+
)
|
|
383
|
+
.run(
|
|
384
|
+
title,
|
|
385
|
+
body,
|
|
386
|
+
this.clock(),
|
|
387
|
+
metadata.categoryId,
|
|
388
|
+
metadata.emoji,
|
|
389
|
+
metadata.sortOrder,
|
|
390
|
+
id,
|
|
391
|
+
);
|
|
620
392
|
});
|
|
621
|
-
return {
|
|
393
|
+
return {
|
|
394
|
+
item: mapPrompt(
|
|
395
|
+
this.db.prepare("SELECT * FROM prompts WHERE id = ?").get(id),
|
|
396
|
+
),
|
|
397
|
+
revision,
|
|
398
|
+
};
|
|
622
399
|
}
|
|
623
400
|
|
|
624
401
|
markPromptUsed(id, input) {
|
|
@@ -629,143 +406,40 @@ export class CodexNotesProduct extends EventEmitter {
|
|
|
629
406
|
});
|
|
630
407
|
}
|
|
631
408
|
if (!this.db.prepare("SELECT 1 FROM prompts WHERE id = ?").get(id)) {
|
|
632
|
-
throw new ProductError("Prompt was not found", {
|
|
409
|
+
throw new ProductError("Prompt was not found", {
|
|
410
|
+
code: "NOT_FOUND",
|
|
411
|
+
status: 404,
|
|
412
|
+
});
|
|
633
413
|
}
|
|
634
414
|
const now = this.clock();
|
|
635
|
-
const { revision } = this.mutate(
|
|
636
|
-
this.db
|
|
415
|
+
const { revision } = this.mutate(() => {
|
|
416
|
+
this.db
|
|
417
|
+
.prepare(
|
|
418
|
+
"UPDATE prompts SET last_used_at = ?, use_count = use_count + ?, called_at = CASE WHEN ? THEN ? ELSE called_at END WHERE id = ?",
|
|
419
|
+
)
|
|
420
|
+
.run(
|
|
421
|
+
now,
|
|
422
|
+
Number(action === "insert"),
|
|
423
|
+
Number(action === "insert"),
|
|
424
|
+
now,
|
|
425
|
+
id,
|
|
426
|
+
);
|
|
637
427
|
});
|
|
638
428
|
return { revision };
|
|
639
429
|
}
|
|
640
430
|
|
|
641
431
|
deletePrompt(id) {
|
|
642
432
|
if (!this.db.prepare("SELECT 1 FROM prompts WHERE id = ?").get(id)) {
|
|
643
|
-
throw new ProductError("Prompt was not found", {
|
|
644
|
-
|
|
645
|
-
|
|
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",
|
|
433
|
+
throw new ProductError("Prompt was not found", {
|
|
434
|
+
code: "NOT_FOUND",
|
|
435
|
+
status: 404,
|
|
735
436
|
});
|
|
736
437
|
}
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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");
|
|
438
|
+
const { result, revision } = this.mutate(
|
|
439
|
+
() => this.db.prepare("DELETE FROM prompts WHERE id = ?").run(id).changes,
|
|
440
|
+
);
|
|
441
|
+
if (!result)
|
|
442
|
+
throw new Error("Prompt disappeared during single-writer deletion");
|
|
769
443
|
return { revision };
|
|
770
444
|
}
|
|
771
445
|
}
|