@relauts/spotcheck-service 1.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/LICENSE +661 -0
- package/NOTICE +19 -0
- package/README.md +169 -0
- package/dist/api/auth.d.ts +11 -0
- package/dist/api/auth.js +32 -0
- package/dist/api/auth.js.map +1 -0
- package/dist/api/history.d.ts +46 -0
- package/dist/api/history.js +172 -0
- package/dist/api/history.js.map +1 -0
- package/dist/api/saved-run.d.ts +35 -0
- package/dist/api/saved-run.js +234 -0
- package/dist/api/saved-run.js.map +1 -0
- package/dist/api/saved.d.ts +33 -0
- package/dist/api/saved.js +209 -0
- package/dist/api/saved.js.map +1 -0
- package/dist/api/server.d.ts +23 -0
- package/dist/api/server.js +852 -0
- package/dist/api/server.js.map +1 -0
- package/dist/computer-use/actions.d.ts +14 -0
- package/dist/computer-use/actions.js +212 -0
- package/dist/computer-use/actions.js.map +1 -0
- package/dist/computer-use/args.d.ts +11 -0
- package/dist/computer-use/args.js +74 -0
- package/dist/computer-use/args.js.map +1 -0
- package/dist/computer-use/cli.d.ts +1 -0
- package/dist/computer-use/cli.js +8 -0
- package/dist/computer-use/cli.js.map +1 -0
- package/dist/computer-use/client.d.ts +3 -0
- package/dist/computer-use/client.js +68 -0
- package/dist/computer-use/client.js.map +1 -0
- package/dist/computer-use/coords.d.ts +5 -0
- package/dist/computer-use/coords.js +21 -0
- package/dist/computer-use/coords.js.map +1 -0
- package/dist/computer-use/history.d.ts +11 -0
- package/dist/computer-use/history.js +59 -0
- package/dist/computer-use/history.js.map +1 -0
- package/dist/computer-use/index.d.ts +17 -0
- package/dist/computer-use/index.js +75 -0
- package/dist/computer-use/index.js.map +1 -0
- package/dist/computer-use/keys.d.ts +3 -0
- package/dist/computer-use/keys.js +50 -0
- package/dist/computer-use/keys.js.map +1 -0
- package/dist/computer-use/loop.d.ts +22 -0
- package/dist/computer-use/loop.js +90 -0
- package/dist/computer-use/loop.js.map +1 -0
- package/dist/computer-use/safety.d.ts +15 -0
- package/dist/computer-use/safety.js +45 -0
- package/dist/computer-use/safety.js.map +1 -0
- package/dist/computer-use/screenshot.d.ts +5 -0
- package/dist/computer-use/screenshot.js +16 -0
- package/dist/computer-use/screenshot.js.map +1 -0
- package/dist/computer-use/system-prompt.d.ts +11 -0
- package/dist/computer-use/system-prompt.js +41 -0
- package/dist/computer-use/system-prompt.js.map +1 -0
- package/dist/computer-use/types.d.ts +83 -0
- package/dist/computer-use/types.js +31 -0
- package/dist/computer-use/types.js.map +1 -0
- package/dist/playwright/actions.d.ts +81 -0
- package/dist/playwright/actions.js +170 -0
- package/dist/playwright/actions.js.map +1 -0
- package/dist/playwright/chromium.d.ts +10 -0
- package/dist/playwright/chromium.js +27 -0
- package/dist/playwright/chromium.js.map +1 -0
- package/dist/playwright/index.d.ts +2 -0
- package/dist/playwright/index.js +64 -0
- package/dist/playwright/index.js.map +1 -0
- package/dist/playwright/processed.d.ts +11 -0
- package/dist/playwright/processed.js +183 -0
- package/dist/playwright/processed.js.map +1 -0
- package/dist/playwright/session.d.ts +28 -0
- package/dist/playwright/session.js +165 -0
- package/dist/playwright/session.js.map +1 -0
- package/dist/shared/config.d.ts +38 -0
- package/dist/shared/config.js +227 -0
- package/dist/shared/config.js.map +1 -0
- package/dist/shared/cost.d.ts +15 -0
- package/dist/shared/cost.js +40 -0
- package/dist/shared/cost.js.map +1 -0
- package/dist/shared/logger.d.ts +4 -0
- package/dist/shared/logger.js +10 -0
- package/dist/shared/logger.js.map +1 -0
- package/dist/shared/main-module.d.ts +1 -0
- package/dist/shared/main-module.js +19 -0
- package/dist/shared/main-module.js.map +1 -0
- package/dist/shared/models.d.ts +12 -0
- package/dist/shared/models.js +40 -0
- package/dist/shared/models.js.map +1 -0
- package/dist/shared/telemetry.d.ts +9 -0
- package/dist/shared/telemetry.js +43 -0
- package/dist/shared/telemetry.js.map +1 -0
- package/files/model.json +26 -0
- package/files/system_prompt.txt +15 -0
- package/openapi.yaml +901 -0
- package/package.json +60 -0
- package/relauts-spotcheck-service-config.example.json +19 -0
- package/src/api/auth.ts +40 -0
- package/src/api/history.ts +251 -0
- package/src/api/saved-run.ts +346 -0
- package/src/api/saved.ts +275 -0
- package/src/api/server.ts +1198 -0
- package/src/computer-use/actions.ts +273 -0
- package/src/computer-use/args.ts +97 -0
- package/src/computer-use/cli.ts +8 -0
- package/src/computer-use/client.ts +81 -0
- package/src/computer-use/coords.ts +26 -0
- package/src/computer-use/history.ts +80 -0
- package/src/computer-use/index.ts +121 -0
- package/src/computer-use/keys.ts +54 -0
- package/src/computer-use/loop.ts +140 -0
- package/src/computer-use/safety.ts +59 -0
- package/src/computer-use/screenshot.ts +20 -0
- package/src/computer-use/system-prompt.ts +65 -0
- package/src/computer-use/types.ts +126 -0
- package/src/playwright/actions.ts +332 -0
- package/src/playwright/chromium.ts +30 -0
- package/src/playwright/index.ts +77 -0
- package/src/playwright/processed.ts +253 -0
- package/src/playwright/session.ts +220 -0
- package/src/shared/config.ts +320 -0
- package/src/shared/cost.ts +58 -0
- package/src/shared/logger.ts +9 -0
- package/src/shared/main-module.ts +20 -0
- package/src/shared/models.ts +62 -0
- package/src/shared/telemetry.ts +63 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { AgentRunError, runAgent, type AgentRunExtras, type AgentRunResult } from "../computer-use/index.js";
|
|
3
|
+
import { launchLightChromium } from "../playwright/chromium.js";
|
|
4
|
+
import { SAVED_RUNS_SUBDIR, savedRunItemScreenshotDir, savedRunScreenshotStem } from "../playwright/processed.js";
|
|
5
|
+
import { createBrowserSession, type BrowserSession } from "../playwright/session.js";
|
|
6
|
+
import type { AppConfig } from "../shared/config.js";
|
|
7
|
+
import { createAutomationReporter, type AutomationReporter } from "../shared/telemetry.js";
|
|
8
|
+
import { computeCostUsd, EMPTY_USAGE } from "../shared/cost.js";
|
|
9
|
+
import { logger } from "../shared/logger.js";
|
|
10
|
+
import { getModelEntry, loadModels } from "../shared/models.js";
|
|
11
|
+
import {
|
|
12
|
+
createHistoryDocument,
|
|
13
|
+
makeHistoryFileName,
|
|
14
|
+
readHistoryFile,
|
|
15
|
+
writeHistoryFile,
|
|
16
|
+
type HistoryDocument,
|
|
17
|
+
type HistoryItem,
|
|
18
|
+
} from "./history.js";
|
|
19
|
+
import {
|
|
20
|
+
CREATE_DEFAULT_FILE,
|
|
21
|
+
isCreateDefaultFileName,
|
|
22
|
+
normalizeSavedFileName,
|
|
23
|
+
readSavedFile,
|
|
24
|
+
SavedNameError,
|
|
25
|
+
type SavedPromptItem,
|
|
26
|
+
} from "./saved.js";
|
|
27
|
+
|
|
28
|
+
export class SavedRunBusyError extends Error {
|
|
29
|
+
constructor(message = "Saved run already running for this file") {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "SavedRunBusyError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class SavedRunLimitError extends Error {
|
|
36
|
+
constructor(message = "Saved run limit reached") {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "SavedRunLimitError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class SavedRunNotRunningError extends Error {
|
|
43
|
+
constructor(message = "No running saved execution") {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "SavedRunNotRunningError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class SavedRunFinishedError extends Error {
|
|
50
|
+
constructor(message = "Saved run already finished") {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "SavedRunFinishedError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type SavedRunTask = (
|
|
57
|
+
task: string,
|
|
58
|
+
model: string,
|
|
59
|
+
session: BrowserSession,
|
|
60
|
+
extras?: AgentRunExtras,
|
|
61
|
+
screenshotDir?: string,
|
|
62
|
+
) => Promise<AgentRunResult>;
|
|
63
|
+
|
|
64
|
+
export interface SavedRunManagerDeps {
|
|
65
|
+
readonly savedDir: string;
|
|
66
|
+
readonly historyDir: string;
|
|
67
|
+
readonly processedDir: string;
|
|
68
|
+
readonly config: AppConfig;
|
|
69
|
+
readonly runTask?: SavedRunTask;
|
|
70
|
+
readonly createSession?: (screenshotDir: string) => BrowserSession;
|
|
71
|
+
readonly now?: () => Date;
|
|
72
|
+
readonly reportAutomation?: AutomationReporter;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface ActiveSavedRun {
|
|
76
|
+
readonly savedFileName: string;
|
|
77
|
+
readonly historyFile: string;
|
|
78
|
+
readonly abort: AbortController;
|
|
79
|
+
readonly session: BrowserSession;
|
|
80
|
+
done: Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function errorMessage(error: unknown): string {
|
|
84
|
+
return error instanceof Error ? error.message : String(error);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function denySavedRunSafety(): Promise<boolean> {
|
|
88
|
+
throw new AgentRunError("Safety confirmation denied", EMPTY_USAGE);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function patchItem(
|
|
92
|
+
document: HistoryDocument,
|
|
93
|
+
sequence: number,
|
|
94
|
+
patch: Partial<HistoryItem> & { status: HistoryItem["status"] },
|
|
95
|
+
): HistoryDocument {
|
|
96
|
+
return {
|
|
97
|
+
...document,
|
|
98
|
+
current: sequence,
|
|
99
|
+
items: document.items.map((item) =>
|
|
100
|
+
item.sequence === sequence
|
|
101
|
+
? {
|
|
102
|
+
sequence: item.sequence,
|
|
103
|
+
id: item.id,
|
|
104
|
+
prompt: item.prompt,
|
|
105
|
+
model: item.model,
|
|
106
|
+
status: patch.status,
|
|
107
|
+
...(patch.resultText !== undefined ? { resultText: patch.resultText } : {}),
|
|
108
|
+
...(patch.costUsd !== undefined ? { costUsd: patch.costUsd } : {}),
|
|
109
|
+
...(patch.error !== undefined ? { error: patch.error } : {}),
|
|
110
|
+
}
|
|
111
|
+
: item,
|
|
112
|
+
),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createSavedRunManager(deps: SavedRunManagerDeps) {
|
|
117
|
+
const createSession =
|
|
118
|
+
deps.createSession ??
|
|
119
|
+
((screenshotDir: string) =>
|
|
120
|
+
createBrowserSession({
|
|
121
|
+
launch: launchLightChromium,
|
|
122
|
+
screenshotDir,
|
|
123
|
+
}));
|
|
124
|
+
const reportAutomation = deps.reportAutomation ?? createAutomationReporter(deps.config);
|
|
125
|
+
|
|
126
|
+
const active = new Map<string, ActiveSavedRun>();
|
|
127
|
+
|
|
128
|
+
async function persist(historyFile: string, document: HistoryDocument): Promise<HistoryDocument> {
|
|
129
|
+
await writeHistoryFile(historyFile, document, deps.historyDir);
|
|
130
|
+
return document;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function costFor(model: string, usage: AgentRunResult["usage"]): Promise<number> {
|
|
134
|
+
try {
|
|
135
|
+
const models = await loadModels();
|
|
136
|
+
return computeCostUsd(usage ?? EMPTY_USAGE, getModelEntry(model, models));
|
|
137
|
+
} catch {
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function callTask(
|
|
143
|
+
task: string,
|
|
144
|
+
model: string,
|
|
145
|
+
session: BrowserSession,
|
|
146
|
+
screenshotDir: string,
|
|
147
|
+
extras: AgentRunExtras,
|
|
148
|
+
): Promise<AgentRunResult> {
|
|
149
|
+
if (deps.runTask) {
|
|
150
|
+
return deps.runTask(task, model, session, extras, screenshotDir);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return runAgent(
|
|
154
|
+
task,
|
|
155
|
+
{ ...deps.config, processedDir: screenshotDir },
|
|
156
|
+
denySavedRunSafety,
|
|
157
|
+
session,
|
|
158
|
+
model,
|
|
159
|
+
{ ...extras, confirmSafety: extras.confirmSafety ?? denySavedRunSafety },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function findActiveByHistoryFile(historyFile: string): ActiveSavedRun | undefined {
|
|
164
|
+
for (const run of active.values()) {
|
|
165
|
+
if (run.historyFile === historyFile) {
|
|
166
|
+
return run;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function execute(
|
|
174
|
+
savedFileName: string,
|
|
175
|
+
historyFile: string,
|
|
176
|
+
items: readonly SavedPromptItem[],
|
|
177
|
+
session: BrowserSession,
|
|
178
|
+
abort: AbortController,
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
let document = await readHistoryFile(historyFile, deps.historyDir);
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
await session.ensurePage(deps.config);
|
|
184
|
+
|
|
185
|
+
for (const item of items) {
|
|
186
|
+
if (abort.signal.aborted) {
|
|
187
|
+
document = await persist(historyFile, { ...document, status: "stopped" });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!item.prompt.trim()) {
|
|
192
|
+
document = await persist(historyFile, patchItem(document, item.sequence, { status: "skipped" }));
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
document = await persist(historyFile, patchItem(document, item.sequence, { status: "running" }));
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const startedAt = new Date();
|
|
200
|
+
const result = await callTask(
|
|
201
|
+
item.prompt,
|
|
202
|
+
item.model,
|
|
203
|
+
session,
|
|
204
|
+
savedRunItemScreenshotDir(deps.processedDir, historyFile, item.sequence, item.id),
|
|
205
|
+
{
|
|
206
|
+
signal: abort.signal,
|
|
207
|
+
confirmSafety: denySavedRunSafety,
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
if (abort.signal.aborted) {
|
|
211
|
+
document = await persist(historyFile, { ...document, status: "stopped" });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
reportAutomation(startedAt, new Date());
|
|
216
|
+
const costUsd = await costFor(item.model, result.usage);
|
|
217
|
+
document = await persist(
|
|
218
|
+
historyFile,
|
|
219
|
+
patchItem(document, item.sequence, {
|
|
220
|
+
status: "done",
|
|
221
|
+
resultText: result.text,
|
|
222
|
+
costUsd,
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
} catch (error: unknown) {
|
|
226
|
+
if (abort.signal.aborted || (error instanceof AgentRunError && error.message === "Agent stopped")) {
|
|
227
|
+
document = await persist(historyFile, { ...document, status: "stopped" });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const usage = error instanceof AgentRunError ? error.usage : EMPTY_USAGE;
|
|
232
|
+
const costUsd = await costFor(item.model, usage);
|
|
233
|
+
document = await persist(historyFile, {
|
|
234
|
+
...patchItem(document, item.sequence, {
|
|
235
|
+
status: "error",
|
|
236
|
+
error: errorMessage(error),
|
|
237
|
+
costUsd,
|
|
238
|
+
}),
|
|
239
|
+
status: "error",
|
|
240
|
+
});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (document.status === "running" && !abort.signal.aborted) {
|
|
246
|
+
await persist(historyFile, { ...document, status: "done", current: document.total });
|
|
247
|
+
} else if (abort.signal.aborted && document.status === "running") {
|
|
248
|
+
await persist(historyFile, { ...document, status: "stopped" });
|
|
249
|
+
}
|
|
250
|
+
} catch (error: unknown) {
|
|
251
|
+
logger.error("Saved run failed", error);
|
|
252
|
+
if (document.status === "running") {
|
|
253
|
+
await persist(historyFile, {
|
|
254
|
+
...document,
|
|
255
|
+
status: abort.signal.aborted ? "stopped" : "error",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
} finally {
|
|
259
|
+
try {
|
|
260
|
+
await session.close();
|
|
261
|
+
} catch (error: unknown) {
|
|
262
|
+
logger.error("Failed to close saved-run browser", error);
|
|
263
|
+
}
|
|
264
|
+
const current = active.get(savedFileName);
|
|
265
|
+
if (current?.historyFile === historyFile) {
|
|
266
|
+
active.delete(savedFileName);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
async start(savedFileName: string): Promise<string> {
|
|
273
|
+
if (isCreateDefaultFileName(savedFileName)) {
|
|
274
|
+
throw new SavedNameError(`${CREATE_DEFAULT_FILE} is reserved`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const saved = await readSavedFile(savedFileName, deps.savedDir);
|
|
278
|
+
if (active.has(saved.fileName)) {
|
|
279
|
+
throw new SavedRunBusyError();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (active.size >= deps.config.maxSavedRuns) {
|
|
283
|
+
throw new SavedRunLimitError();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const historyFile = makeHistoryFileName(saved.fileName, deps.now?.() ?? new Date());
|
|
287
|
+
const screenshotDir = path.join(
|
|
288
|
+
deps.processedDir,
|
|
289
|
+
SAVED_RUNS_SUBDIR,
|
|
290
|
+
savedRunScreenshotStem(historyFile),
|
|
291
|
+
);
|
|
292
|
+
const session = createSession(screenshotDir);
|
|
293
|
+
const abort = new AbortController();
|
|
294
|
+
const run: ActiveSavedRun = {
|
|
295
|
+
savedFileName: saved.fileName,
|
|
296
|
+
historyFile,
|
|
297
|
+
abort,
|
|
298
|
+
session,
|
|
299
|
+
done: Promise.resolve(),
|
|
300
|
+
};
|
|
301
|
+
active.set(saved.fileName, run);
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
await persist(historyFile, createHistoryDocument(saved.fileName, saved.items));
|
|
305
|
+
} catch (error: unknown) {
|
|
306
|
+
active.delete(saved.fileName);
|
|
307
|
+
await session.close();
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
run.done = execute(saved.fileName, historyFile, saved.items, session, abort);
|
|
312
|
+
return historyFile;
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
async stop(historyFileName: string): Promise<void> {
|
|
316
|
+
const name = normalizeSavedFileName(historyFileName);
|
|
317
|
+
const current = findActiveByHistoryFile(name);
|
|
318
|
+
if (!current) {
|
|
319
|
+
const document = await readHistoryFile(name, deps.historyDir);
|
|
320
|
+
if (document.status !== "running") {
|
|
321
|
+
throw new SavedRunFinishedError();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
throw new SavedRunNotRunningError();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
current.abort.abort();
|
|
328
|
+
await current.done;
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
async close(): Promise<void> {
|
|
332
|
+
const runs = [...active.values()];
|
|
333
|
+
for (const run of runs) {
|
|
334
|
+
run.abort.abort();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
await Promise.all(runs.map((run) => run.done));
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
listRunning(): string[] {
|
|
341
|
+
return [...active.keys()].sort((left, right) => left.localeCompare(right));
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export type SavedRunManager = ReturnType<typeof createSavedRunManager>;
|
package/src/api/saved.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const SAVED_DIR = path.resolve(process.cwd(), "saved");
|
|
5
|
+
export const CREATE_DEFAULT_FILE = "create-default.json";
|
|
6
|
+
|
|
7
|
+
export interface SavedPromptItem {
|
|
8
|
+
readonly sequence: number;
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly prompt: string;
|
|
11
|
+
readonly model: string;
|
|
12
|
+
readonly costUsd?: number;
|
|
13
|
+
readonly resultText?: string;
|
|
14
|
+
readonly sessionReset?: boolean;
|
|
15
|
+
readonly error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class SavedNameError extends Error {}
|
|
19
|
+
export class SavedExistsError extends Error {}
|
|
20
|
+
export class SavedNotFoundError extends Error {}
|
|
21
|
+
|
|
22
|
+
function isSavedPromptItem(value: unknown, requirePrompt: boolean): value is SavedPromptItem {
|
|
23
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const item = value as Record<string, unknown>;
|
|
28
|
+
if (
|
|
29
|
+
typeof item.sequence !== "number" ||
|
|
30
|
+
!Number.isInteger(item.sequence) ||
|
|
31
|
+
item.sequence <= 0 ||
|
|
32
|
+
typeof item.id !== "string" ||
|
|
33
|
+
item.id.trim().length === 0 ||
|
|
34
|
+
typeof item.prompt !== "string" ||
|
|
35
|
+
(requirePrompt && item.prompt.trim().length === 0) ||
|
|
36
|
+
typeof item.model !== "string" ||
|
|
37
|
+
item.model.trim().length === 0
|
|
38
|
+
) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (item.costUsd !== undefined && (typeof item.costUsd !== "number" || !Number.isFinite(item.costUsd))) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (item.resultText !== undefined && typeof item.resultText !== "string") {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (item.sessionReset !== undefined && typeof item.sessionReset !== "boolean") {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (item.error !== undefined && typeof item.error !== "string") {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function normalizeSavedFileName(raw: string): string {
|
|
59
|
+
const trimmed = raw.trim();
|
|
60
|
+
if (!trimmed) {
|
|
61
|
+
throw new SavedNameError("File name is required");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const withExt = trimmed.toLowerCase().endsWith(".json") ? `${trimmed.slice(0, -5)}.json` : `${trimmed}.json`;
|
|
65
|
+
if (withExt !== path.basename(withExt) || withExt.includes("..") || withExt === ".json") {
|
|
66
|
+
throw new SavedNameError("File name must not contain a path");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const stem = withExt.slice(0, -5);
|
|
70
|
+
if (!/^[a-zA-Z0-9._ -]+$/.test(stem) || stem.trim().length === 0) {
|
|
71
|
+
throw new SavedNameError("File name can only use letters, numbers, space, dot, dash, and underscore");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return withExt;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isCreateDefaultFileName(fileName: string): boolean {
|
|
78
|
+
return normalizeSavedFileName(fileName).toLowerCase() === CREATE_DEFAULT_FILE;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function uniqueSavedItems(items: readonly SavedPromptItem[]): SavedPromptItem[] {
|
|
82
|
+
const byId = new Map<string, SavedPromptItem>();
|
|
83
|
+
for (const item of items) {
|
|
84
|
+
byId.set(item.id, item);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return [...byId.values()]
|
|
88
|
+
.sort((left, right) => left.sequence - right.sequence)
|
|
89
|
+
.map((item, index) => ({
|
|
90
|
+
sequence: index + 1,
|
|
91
|
+
id: item.id,
|
|
92
|
+
prompt: item.prompt.trim(),
|
|
93
|
+
model: item.model.trim(),
|
|
94
|
+
...(typeof item.costUsd === "number" ? { costUsd: item.costUsd } : {}),
|
|
95
|
+
...(item.resultText ? { resultText: item.resultText } : {}),
|
|
96
|
+
...(item.sessionReset ? { sessionReset: true } : {}),
|
|
97
|
+
...(item.error ? { error: item.error } : {}),
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function parseSavedItems(value: unknown): SavedPromptItem[] {
|
|
102
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
103
|
+
throw new SavedNameError("items must be a non-empty array");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!value.every((item) => isSavedPromptItem(item, true))) {
|
|
107
|
+
throw new SavedNameError("items has invalid saved prompt entries");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return uniqueSavedItems(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseCreateItems(value: unknown): SavedPromptItem[] {
|
|
114
|
+
if (!Array.isArray(value)) {
|
|
115
|
+
throw new SavedNameError("items must be an array");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (value.length === 0) {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!value.every((item) => isSavedPromptItem(item, false))) {
|
|
123
|
+
throw new SavedNameError("items has invalid saved prompt entries");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return uniqueSavedItems(value);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function listSavedFiles(directory = SAVED_DIR): Promise<string[]> {
|
|
130
|
+
let names: string[];
|
|
131
|
+
try {
|
|
132
|
+
names = await fs.readdir(directory);
|
|
133
|
+
} catch (error: unknown) {
|
|
134
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const jsonNames = names.filter((name) => name.toLowerCase().endsWith(".json"));
|
|
142
|
+
jsonNames.sort((left, right) => left.localeCompare(right));
|
|
143
|
+
return jsonNames;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function writeJsonFile(
|
|
147
|
+
directory: string,
|
|
148
|
+
name: string,
|
|
149
|
+
items: readonly SavedPromptItem[],
|
|
150
|
+
flag: "wx" | "w",
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
await fs.mkdir(directory, { recursive: true });
|
|
153
|
+
await fs.writeFile(path.join(directory, name), `${JSON.stringify(items, null, 2)}\n`, { flag });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function writeSavedFile(
|
|
157
|
+
fileName: string,
|
|
158
|
+
items: readonly SavedPromptItem[],
|
|
159
|
+
directory = SAVED_DIR,
|
|
160
|
+
): Promise<string> {
|
|
161
|
+
const name = normalizeSavedFileName(fileName);
|
|
162
|
+
if (name.toLowerCase() === CREATE_DEFAULT_FILE) {
|
|
163
|
+
throw new SavedNameError(`${CREATE_DEFAULT_FILE} is reserved`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const uniqueItems = uniqueSavedItems(items);
|
|
167
|
+
if (uniqueItems.length === 0) {
|
|
168
|
+
throw new SavedNameError("items must be a non-empty array");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const existing = await listSavedFiles(directory);
|
|
172
|
+
if (existing.some((entry) => entry.toLowerCase() === name.toLowerCase())) {
|
|
173
|
+
throw new SavedExistsError(`File already exists: ${name}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await writeJsonFile(directory, name, uniqueItems, "wx");
|
|
177
|
+
return name;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function upsertSavedFile(
|
|
181
|
+
fileName: string,
|
|
182
|
+
items: readonly SavedPromptItem[],
|
|
183
|
+
directory = SAVED_DIR,
|
|
184
|
+
): Promise<string> {
|
|
185
|
+
const name = normalizeSavedFileName(fileName);
|
|
186
|
+
await writeJsonFile(directory, name, uniqueSavedItems(items), "w");
|
|
187
|
+
return name;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function overwriteSavedFile(
|
|
191
|
+
fileName: string,
|
|
192
|
+
items: readonly SavedPromptItem[],
|
|
193
|
+
directory = SAVED_DIR,
|
|
194
|
+
): Promise<string> {
|
|
195
|
+
const name = normalizeSavedFileName(fileName);
|
|
196
|
+
if (name.toLowerCase() === CREATE_DEFAULT_FILE) {
|
|
197
|
+
throw new SavedNameError(`${CREATE_DEFAULT_FILE} is reserved`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const filePath = path.join(directory, name);
|
|
201
|
+
try {
|
|
202
|
+
await fs.access(filePath);
|
|
203
|
+
} catch (error: unknown) {
|
|
204
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
205
|
+
throw new SavedNotFoundError(`Saved file not found: ${name}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const uniqueItems = uniqueSavedItems(items);
|
|
212
|
+
if (uniqueItems.length === 0) {
|
|
213
|
+
throw new SavedNameError("items must be a non-empty array");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await writeJsonFile(directory, name, uniqueItems, "w");
|
|
217
|
+
return name;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function deleteSavedFile(fileName: string, directory = SAVED_DIR): Promise<string> {
|
|
221
|
+
const name = normalizeSavedFileName(fileName);
|
|
222
|
+
if (name.toLowerCase() === CREATE_DEFAULT_FILE) {
|
|
223
|
+
throw new SavedNameError(`${CREATE_DEFAULT_FILE} is reserved`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
await fs.unlink(path.join(directory, name));
|
|
228
|
+
} catch (error: unknown) {
|
|
229
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
230
|
+
throw new SavedNotFoundError(`Saved file not found: ${name}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return name;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function ensureCreateDefaultFile(directory = SAVED_DIR): Promise<void> {
|
|
240
|
+
await fs.mkdir(directory, { recursive: true });
|
|
241
|
+
const filePath = path.join(directory, CREATE_DEFAULT_FILE);
|
|
242
|
+
try {
|
|
243
|
+
await fs.access(filePath);
|
|
244
|
+
} catch {
|
|
245
|
+
await writeJsonFile(directory, CREATE_DEFAULT_FILE, [], "wx");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function readSavedFile(
|
|
250
|
+
fileName: string,
|
|
251
|
+
directory = SAVED_DIR,
|
|
252
|
+
): Promise<{ fileName: string; items: SavedPromptItem[] }> {
|
|
253
|
+
const name = normalizeSavedFileName(fileName);
|
|
254
|
+
const filePath = path.join(directory, name);
|
|
255
|
+
let raw: string;
|
|
256
|
+
try {
|
|
257
|
+
raw = await fs.readFile(filePath, "utf8");
|
|
258
|
+
} catch (error: unknown) {
|
|
259
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
260
|
+
throw new SavedNotFoundError(`Saved file not found: ${name}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let parsed: unknown;
|
|
267
|
+
try {
|
|
268
|
+
parsed = JSON.parse(raw);
|
|
269
|
+
} catch {
|
|
270
|
+
throw new SavedNameError("Saved file is not valid JSON");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const items = name.toLowerCase() === CREATE_DEFAULT_FILE ? parseCreateItems(parsed) : parseSavedItems(parsed);
|
|
274
|
+
return { fileName: name, items };
|
|
275
|
+
}
|