waypoi 0.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/.github/instructions/ui.instructions.md +42 -0
- package/.github/workflows/ci.yml +35 -0
- package/.github/workflows/publish.yml +71 -0
- package/.github/workflows/release.yml +48 -0
- package/.playwright-mcp/console-2026-04-04T01-41-10-746Z.log +2 -0
- package/.playwright-mcp/console-2026-04-04T01-41-28-799Z.log +3 -0
- package/.playwright-mcp/console-2026-04-05T02-26-51-909Z.log +76 -0
- package/.playwright-mcp/page-2026-04-04T01-41-10-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-04T01-41-29-141Z.yml +77 -0
- package/.playwright-mcp/page-2026-04-04T01-41-42-633Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T01-42-03-929Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-12-54-813Z.yml +6 -0
- package/.playwright-mcp/page-2026-04-04T02-14-58-600Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-03-923Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-07-426Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-25-729Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-16-22-984Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-17-00-599Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-17-50-874Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-05T02-26-55-570Z.yml +6 -0
- package/AGENTS.md +48 -0
- package/CHANGELOG.md +131 -0
- package/README.md +552 -0
- package/assets/agent-mode.png +0 -0
- package/assets/categorize.png +0 -0
- package/assets/dashboard.png +0 -0
- package/assets/endpoint-proxy.png +0 -0
- package/assets/icon.png +0 -0
- package/assets/mcp-generate-image.png +0 -0
- package/assets/mcp-understand-image.png +0 -0
- package/assets/peek-token-flow.png +0 -0
- package/assets/playground.png +0 -0
- package/assets/sankey.png +0 -0
- package/cli/index.ts +2805 -0
- package/cli/legacyRewrite.ts +108 -0
- package/cli/modelRef.ts +24 -0
- package/dist/cli/index.js +2536 -0
- package/dist/cli/legacyRewrite.js +92 -0
- package/dist/cli/modelRef.js +20 -0
- package/dist/src/benchmark/artifacts.js +131 -0
- package/dist/src/benchmark/capabilityClassifier.js +81 -0
- package/dist/src/benchmark/capabilityStore.js +144 -0
- package/dist/src/benchmark/config.js +238 -0
- package/dist/src/benchmark/gates.js +118 -0
- package/dist/src/benchmark/jobs.js +252 -0
- package/dist/src/benchmark/runner.js +1847 -0
- package/dist/src/benchmark/schema.js +353 -0
- package/dist/src/benchmark/suites.js +314 -0
- package/dist/src/benchmark/tinyQaDataset.js +422 -0
- package/dist/src/benchmark/types.js +25 -0
- package/dist/src/config.js +47 -0
- package/dist/src/index.js +178 -0
- package/dist/src/mcp/client.js +215 -0
- package/dist/src/mcp/discovery.js +226 -0
- package/dist/src/mcp/policy.js +65 -0
- package/dist/src/mcp/registry.js +129 -0
- package/dist/src/mcp/service.js +460 -0
- package/dist/src/middleware/auth.js +179 -0
- package/dist/src/middleware/requestCapture.js +192 -0
- package/dist/src/middleware/requestStats.js +118 -0
- package/dist/src/pools/builder.js +132 -0
- package/dist/src/pools/repository.js +69 -0
- package/dist/src/pools/scheduler.js +360 -0
- package/dist/src/pools/types.js +2 -0
- package/dist/src/protocols/adapters/dashscope.js +267 -0
- package/dist/src/protocols/adapters/inferenceV2.js +346 -0
- package/dist/src/protocols/adapters/openai.js +27 -0
- package/dist/src/protocols/registry.js +99 -0
- package/dist/src/protocols/types.js +2 -0
- package/dist/src/providers/health.js +153 -0
- package/dist/src/providers/importer.js +289 -0
- package/dist/src/providers/modelRegistry.js +313 -0
- package/dist/src/providers/repository.js +361 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/routes/admin.js +531 -0
- package/dist/src/routes/audio.js +295 -0
- package/dist/src/routes/chat.js +240 -0
- package/dist/src/routes/embeddings.js +157 -0
- package/dist/src/routes/images.js +288 -0
- package/dist/src/routes/mcp.js +256 -0
- package/dist/src/routes/mcpService.js +100 -0
- package/dist/src/routes/models.js +48 -0
- package/dist/src/routes/responses.js +711 -0
- package/dist/src/routes/sessions.js +450 -0
- package/dist/src/routes/stats.js +270 -0
- package/dist/src/routes/ui.js +97 -0
- package/dist/src/routes/videos.js +107 -0
- package/dist/src/routing/router.js +338 -0
- package/dist/src/services/imageGeneration.js +280 -0
- package/dist/src/services/imageUnderstanding.js +352 -0
- package/dist/src/services/videoGeneration.js +79 -0
- package/dist/src/storage/captureRepository.js +1591 -0
- package/dist/src/storage/files.js +157 -0
- package/dist/src/storage/imageCache.js +346 -0
- package/dist/src/storage/repositories.js +388 -0
- package/dist/src/storage/sessionRepository.js +370 -0
- package/dist/src/storage/statsRepository.js +204 -0
- package/dist/src/transport/httpClient.js +126 -0
- package/dist/src/types.js +2 -0
- package/dist/src/utils/messageMedia.js +285 -0
- package/dist/src/utils/modelCapabilities.js +108 -0
- package/dist/src/utils/modelDiscovery.js +170 -0
- package/dist/src/version.js +5 -0
- package/dist/src/workers/captureRetention.js +25 -0
- package/dist/src/workers/configWatcher.js +91 -0
- package/dist/src/workers/healthChecker.js +21 -0
- package/dist/src/workers/statsRotation.js +41 -0
- package/docs/LLM/output_schema.md +312 -0
- package/docs/benchmark.md +208 -0
- package/docs/mcp-guidelines.md +125 -0
- package/docs/mcp-service.md +178 -0
- package/docs/opencode.md +86 -0
- package/docs/providers.md +79 -0
- package/examples/benchmark.config.yaml +28 -0
- package/examples/providers/alibaba-dashscope.yaml +88 -0
- package/examples/providers/alibaba-llm.yaml +64 -0
- package/examples/providers/alibaba-registry.yaml +7 -0
- package/examples/providers/inference-v2-ray.yaml +29 -0
- package/examples/scenarios/assets/omni-call-sample.wav +0 -0
- package/examples/scenarios/custom.jsonl +5 -0
- package/examples/scenarios/custom.yaml +40 -0
- package/model-form-v2.png +0 -0
- package/package.json +66 -0
- package/provider-form-v2.png +0 -0
- package/provider-form.png +0 -0
- package/scripts/manual-test.sh +11 -0
- package/scripts/version-from-git.js +23 -0
- package/src/benchmark/artifacts.ts +149 -0
- package/src/benchmark/capabilityClassifier.ts +99 -0
- package/src/benchmark/capabilityStore.ts +174 -0
- package/src/benchmark/config.ts +337 -0
- package/src/benchmark/gates.ts +164 -0
- package/src/benchmark/jobs.ts +312 -0
- package/src/benchmark/runner.ts +2519 -0
- package/src/benchmark/schema.ts +443 -0
- package/src/benchmark/suites.ts +323 -0
- package/src/benchmark/tinyQaDataset.ts +428 -0
- package/src/benchmark/types.ts +442 -0
- package/src/config.ts +44 -0
- package/src/index.ts +195 -0
- package/src/mcp/client.ts +305 -0
- package/src/mcp/discovery.ts +266 -0
- package/src/mcp/policy.ts +105 -0
- package/src/mcp/registry.ts +164 -0
- package/src/mcp/service.ts +611 -0
- package/src/middleware/auth.ts +251 -0
- package/src/middleware/requestCapture.ts +245 -0
- package/src/middleware/requestStats.ts +163 -0
- package/src/pools/builder.ts +159 -0
- package/src/pools/repository.ts +71 -0
- package/src/pools/scheduler.ts +425 -0
- package/src/pools/types.ts +117 -0
- package/src/protocols/adapters/dashscope.ts +335 -0
- package/src/protocols/adapters/inferenceV2.ts +428 -0
- package/src/protocols/adapters/openai.ts +32 -0
- package/src/protocols/registry.ts +117 -0
- package/src/protocols/types.ts +81 -0
- package/src/providers/health.ts +207 -0
- package/src/providers/importer.ts +402 -0
- package/src/providers/modelRegistry.ts +415 -0
- package/src/providers/repository.ts +439 -0
- package/src/providers/types.ts +113 -0
- package/src/routes/admin.ts +666 -0
- package/src/routes/audio.ts +372 -0
- package/src/routes/chat.ts +301 -0
- package/src/routes/embeddings.ts +197 -0
- package/src/routes/images.ts +356 -0
- package/src/routes/mcp.ts +320 -0
- package/src/routes/mcpService.ts +114 -0
- package/src/routes/models.ts +50 -0
- package/src/routes/responses.ts +872 -0
- package/src/routes/sessions.ts +558 -0
- package/src/routes/stats.ts +312 -0
- package/src/routes/ui.ts +96 -0
- package/src/routes/videos.ts +132 -0
- package/src/routing/router.ts +501 -0
- package/src/services/imageGeneration.ts +396 -0
- package/src/services/imageUnderstanding.ts +449 -0
- package/src/services/videoGeneration.ts +127 -0
- package/src/storage/captureRepository.ts +1835 -0
- package/src/storage/files.ts +178 -0
- package/src/storage/imageCache.ts +405 -0
- package/src/storage/repositories.ts +494 -0
- package/src/storage/sessionRepository.ts +419 -0
- package/src/storage/statsRepository.ts +238 -0
- package/src/transport/httpClient.ts +145 -0
- package/src/types.ts +322 -0
- package/src/utils/messageMedia.ts +293 -0
- package/src/utils/modelCapabilities.ts +161 -0
- package/src/utils/modelDiscovery.ts +203 -0
- package/src/workers/captureRetention.ts +25 -0
- package/src/workers/configWatcher.ts +115 -0
- package/src/workers/healthChecker.ts +22 -0
- package/src/workers/statsRotation.ts +49 -0
- package/tests/benchmarkAdminRoutes.test.ts +82 -0
- package/tests/benchmarkBasics.test.ts +116 -0
- package/tests/captureAdminRoutes.test.ts +420 -0
- package/tests/captureRepository.test.ts +797 -0
- package/tests/cliLegacyRewrite.test.ts +45 -0
- package/tests/imageGeneration.service.test.ts +107 -0
- package/tests/imageUnderstanding.service.test.ts +123 -0
- package/tests/mcpPolicy.test.ts +105 -0
- package/tests/mcpService.test.ts +1245 -0
- package/tests/modelRef.test.ts +23 -0
- package/tests/modelsRoutes.test.ts +154 -0
- package/tests/sessionMediaCache.test.ts +167 -0
- package/tests/statsRoutes.test.ts +323 -0
- package/tsconfig.json +15 -0
- package/ui/index.html +16 -0
- package/ui/package-lock.json +8521 -0
- package/ui/package.json +52 -0
- package/ui/postcss.config.js +6 -0
- package/ui/public/assets/apple-touch-icon.png +0 -0
- package/ui/public/assets/favicon-16.png +0 -0
- package/ui/public/assets/favicon-32.png +0 -0
- package/ui/public/assets/icon-192.png +0 -0
- package/ui/public/assets/icon-512.png +0 -0
- package/ui/src/App.tsx +27 -0
- package/ui/src/api/client.ts +1503 -0
- package/ui/src/components/EndpointUsageGuide.tsx +361 -0
- package/ui/src/components/Layout.tsx +124 -0
- package/ui/src/components/MessageContent.tsx +365 -0
- package/ui/src/components/ToolCallMessage.tsx +179 -0
- package/ui/src/components/ToolPicker.tsx +442 -0
- package/ui/src/components/messageContentParser.test.ts +41 -0
- package/ui/src/components/messageContentParser.ts +73 -0
- package/ui/src/components/thinkingPreview.test.ts +27 -0
- package/ui/src/components/thinkingPreview.ts +15 -0
- package/ui/src/components/toMermaidSankey.test.ts +78 -0
- package/ui/src/components/toMermaidSankey.ts +56 -0
- package/ui/src/components/ui/button.tsx +58 -0
- package/ui/src/components/ui/input.tsx +21 -0
- package/ui/src/components/ui/textarea.tsx +21 -0
- package/ui/src/lib/utils.ts +6 -0
- package/ui/src/main.tsx +9 -0
- package/ui/src/pages/AgentPlayground.tsx +2010 -0
- package/ui/src/pages/Benchmark.tsx +988 -0
- package/ui/src/pages/Dashboard.tsx +581 -0
- package/ui/src/pages/Peek.tsx +962 -0
- package/ui/src/pages/Settings.tsx +2013 -0
- package/ui/src/pages/agentPlaygroundPayload.test.ts +109 -0
- package/ui/src/pages/agentPlaygroundPayload.ts +97 -0
- package/ui/src/pages/agentThinkingContent.test.ts +50 -0
- package/ui/src/pages/agentThinkingContent.ts +57 -0
- package/ui/src/pages/dashboardTokenUsage.test.ts +66 -0
- package/ui/src/pages/dashboardTokenUsage.ts +36 -0
- package/ui/src/pages/imageUpload.test.ts +39 -0
- package/ui/src/pages/imageUpload.ts +71 -0
- package/ui/src/pages/peekFilters.test.ts +29 -0
- package/ui/src/pages/peekFilters.ts +13 -0
- package/ui/src/pages/peekMedia.test.ts +58 -0
- package/ui/src/pages/peekMedia.ts +148 -0
- package/ui/src/pages/sessionAutoTitle.test.ts +128 -0
- package/ui/src/pages/sessionAutoTitle.ts +106 -0
- package/ui/src/stores/settings.ts +58 -0
- package/ui/src/styles/globals.css +223 -0
- package/ui/src/vite-env.d.ts +8 -0
- package/ui/tailwind.config.js +106 -0
- package/ui/tsconfig.json +32 -0
- package/ui/vite.config.ts +37 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
import { ChatSession, ChatMessage } from "../types";
|
|
5
|
+
import { StoragePaths, ensureStorageDir } from "./files";
|
|
6
|
+
import { storeMedia, syncSessionMediaReferences, unmarkSessionMediaReferences, cleanOrphanedMedia } from "./imageCache";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Session Repository
|
|
10
|
+
*
|
|
11
|
+
* Manages chat sessions for the playground UI.
|
|
12
|
+
* Sessions are stored as JSON files in ~/.config/waypoi/sessions/
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export function resolveSessionsDir(paths: StoragePaths): string {
|
|
16
|
+
return path.join(paths.baseDir, "sessions");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function ensureSessionsDir(paths: StoragePaths): Promise<void> {
|
|
20
|
+
await ensureStorageDir(paths);
|
|
21
|
+
const sessionsDir = resolveSessionsDir(paths);
|
|
22
|
+
await fs.mkdir(sessionsDir, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sessionFilePath(paths: StoragePaths, sessionId: string): string {
|
|
26
|
+
return path.join(resolveSessionsDir(paths), `${sessionId}.json`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function listSessions(paths: StoragePaths): Promise<ChatSession[]> {
|
|
30
|
+
await ensureSessionsDir(paths);
|
|
31
|
+
const sessionsDir = resolveSessionsDir(paths);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const files = await fs.readdir(sessionsDir);
|
|
35
|
+
const sessions: ChatSession[] = [];
|
|
36
|
+
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
if (!file.endsWith(".json")) continue;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const filePath = path.join(sessionsDir, file);
|
|
42
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
43
|
+
let session = parseSession(JSON.parse(raw) as ChatSession);
|
|
44
|
+
const migrated = await migrateSessionMediaRefs(paths, session);
|
|
45
|
+
if (migrated.changed) {
|
|
46
|
+
session = migrated.session;
|
|
47
|
+
await saveSession(paths, session);
|
|
48
|
+
}
|
|
49
|
+
sessions.push(session);
|
|
50
|
+
} catch {
|
|
51
|
+
// Skip malformed session files
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Sort by updatedAt descending (most recent first)
|
|
56
|
+
return sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function getSession(paths: StoragePaths, sessionId: string): Promise<ChatSession | null> {
|
|
66
|
+
await ensureSessionsDir(paths);
|
|
67
|
+
const filePath = sessionFilePath(paths, sessionId);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
71
|
+
let session = parseSession(JSON.parse(raw) as ChatSession);
|
|
72
|
+
const migrated = await migrateSessionMediaRefs(paths, session);
|
|
73
|
+
if (migrated.changed) {
|
|
74
|
+
session = migrated.session;
|
|
75
|
+
await saveSession(paths, session);
|
|
76
|
+
}
|
|
77
|
+
return session;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function createSession(
|
|
87
|
+
paths: StoragePaths,
|
|
88
|
+
input: { name?: string; model?: string }
|
|
89
|
+
): Promise<ChatSession> {
|
|
90
|
+
await ensureSessionsDir(paths);
|
|
91
|
+
|
|
92
|
+
const now = new Date();
|
|
93
|
+
const session: ChatSession = {
|
|
94
|
+
id: crypto.randomUUID(),
|
|
95
|
+
name: input.name ?? `Session ${now.toLocaleDateString()}`,
|
|
96
|
+
model: input.model,
|
|
97
|
+
titleStatus: input.name ? "manual" : "pending",
|
|
98
|
+
titleUpdatedAt: now,
|
|
99
|
+
storageVersion: 2,
|
|
100
|
+
messages: [],
|
|
101
|
+
createdAt: now,
|
|
102
|
+
updatedAt: now,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
await saveSession(paths, session);
|
|
106
|
+
return session;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function updateSession(
|
|
110
|
+
paths: StoragePaths,
|
|
111
|
+
sessionId: string,
|
|
112
|
+
patch: Partial<Pick<ChatSession, "name" | "model" | "titleStatus" | "titleUpdatedAt">>
|
|
113
|
+
): Promise<ChatSession | null> {
|
|
114
|
+
const session = await getSession(paths, sessionId);
|
|
115
|
+
if (!session) return null;
|
|
116
|
+
|
|
117
|
+
const titleStatus =
|
|
118
|
+
patch.titleStatus ??
|
|
119
|
+
(patch.name !== undefined ? "manual" : session.titleStatus);
|
|
120
|
+
const titleUpdatedAt =
|
|
121
|
+
patch.titleUpdatedAt ??
|
|
122
|
+
(patch.name !== undefined || patch.titleStatus !== undefined ? new Date() : session.titleUpdatedAt);
|
|
123
|
+
|
|
124
|
+
const updated: ChatSession = {
|
|
125
|
+
...session,
|
|
126
|
+
...patch,
|
|
127
|
+
titleStatus,
|
|
128
|
+
titleUpdatedAt,
|
|
129
|
+
updatedAt: new Date(),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
await saveSession(paths, updated);
|
|
133
|
+
return updated;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function deleteSession(paths: StoragePaths, sessionId: string): Promise<boolean> {
|
|
137
|
+
const filePath = sessionFilePath(paths, sessionId);
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await unmarkSessionMediaReferences(paths, sessionId);
|
|
141
|
+
await fs.unlink(filePath);
|
|
142
|
+
// Free any media that is now unreferenced
|
|
143
|
+
await cleanOrphanedMedia(paths);
|
|
144
|
+
return true;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function addMessage(
|
|
154
|
+
paths: StoragePaths,
|
|
155
|
+
sessionId: string,
|
|
156
|
+
message: Omit<ChatMessage, "id" | "createdAt">
|
|
157
|
+
): Promise<ChatMessage | null> {
|
|
158
|
+
const session = await getSession(paths, sessionId);
|
|
159
|
+
if (!session) return null;
|
|
160
|
+
|
|
161
|
+
const normalizedMessage = await normalizeMessageMediaRefs(paths, message);
|
|
162
|
+
|
|
163
|
+
const newMessage: ChatMessage = {
|
|
164
|
+
...normalizedMessage,
|
|
165
|
+
id: crypto.randomUUID(),
|
|
166
|
+
createdAt: new Date(),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
session.messages.push(newMessage);
|
|
170
|
+
session.updatedAt = new Date();
|
|
171
|
+
|
|
172
|
+
await saveSession(paths, session);
|
|
173
|
+
return newMessage;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function appendMessageContent(
|
|
177
|
+
paths: StoragePaths,
|
|
178
|
+
sessionId: string,
|
|
179
|
+
messageId: string,
|
|
180
|
+
content: string
|
|
181
|
+
): Promise<boolean> {
|
|
182
|
+
const session = await getSession(paths, sessionId);
|
|
183
|
+
if (!session) return false;
|
|
184
|
+
|
|
185
|
+
const message = session.messages.find((m) => m.id === messageId);
|
|
186
|
+
if (!message) return false;
|
|
187
|
+
|
|
188
|
+
message.content = (message.content ?? "") + content;
|
|
189
|
+
session.updatedAt = new Date();
|
|
190
|
+
|
|
191
|
+
await saveSession(paths, session);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function saveSession(paths: StoragePaths, session: ChatSession): Promise<void> {
|
|
196
|
+
const filePath = sessionFilePath(paths, session.id);
|
|
197
|
+
const json = JSON.stringify(session, null, 2);
|
|
198
|
+
await fs.writeFile(filePath, json, "utf8");
|
|
199
|
+
await syncSessionMediaReferences(paths, session.id, extractMediaHashesFromSession(session));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function parseSession(raw: ChatSession): ChatSession {
|
|
203
|
+
const session: ChatSession = {
|
|
204
|
+
...raw,
|
|
205
|
+
storageVersion: typeof raw.storageVersion === "number" ? raw.storageVersion : 1,
|
|
206
|
+
titleStatus:
|
|
207
|
+
raw.titleStatus === "pending" ||
|
|
208
|
+
raw.titleStatus === "generated" ||
|
|
209
|
+
raw.titleStatus === "manual" ||
|
|
210
|
+
raw.titleStatus === "failed"
|
|
211
|
+
? raw.titleStatus
|
|
212
|
+
: undefined,
|
|
213
|
+
titleUpdatedAt: raw.titleUpdatedAt ? new Date(raw.titleUpdatedAt) : undefined,
|
|
214
|
+
createdAt: new Date(raw.createdAt),
|
|
215
|
+
updatedAt: new Date(raw.updatedAt),
|
|
216
|
+
messages: Array.isArray(raw.messages)
|
|
217
|
+
? raw.messages.map((message) => ({
|
|
218
|
+
...message,
|
|
219
|
+
createdAt: parseMessageDate(message),
|
|
220
|
+
}))
|
|
221
|
+
: [],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return session;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parseMessageDate(message: ChatMessage & { timestamp?: string }): Date {
|
|
228
|
+
if (message.createdAt) {
|
|
229
|
+
return new Date(message.createdAt);
|
|
230
|
+
}
|
|
231
|
+
if (typeof message.timestamp === "string") {
|
|
232
|
+
return new Date(message.timestamp);
|
|
233
|
+
}
|
|
234
|
+
return new Date();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function migrateSessionMediaRefs(
|
|
238
|
+
paths: StoragePaths,
|
|
239
|
+
session: ChatSession
|
|
240
|
+
): Promise<{ session: ChatSession; changed: boolean }> {
|
|
241
|
+
let changed = false;
|
|
242
|
+
const migratedMessages: ChatMessage[] = [];
|
|
243
|
+
|
|
244
|
+
for (const message of session.messages) {
|
|
245
|
+
const normalized = await normalizeMessageMediaRefs(paths, message);
|
|
246
|
+
if (!changed && JSON.stringify(normalized) !== JSON.stringify(message)) {
|
|
247
|
+
changed = true;
|
|
248
|
+
}
|
|
249
|
+
migratedMessages.push({
|
|
250
|
+
...normalized,
|
|
251
|
+
id: message.id,
|
|
252
|
+
createdAt: message.createdAt,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const nextStorageVersion = session.storageVersion >= 2 ? session.storageVersion : 2;
|
|
257
|
+
if (nextStorageVersion !== session.storageVersion) {
|
|
258
|
+
changed = true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!changed) {
|
|
262
|
+
return { session, changed: false };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
changed: true,
|
|
267
|
+
session: {
|
|
268
|
+
...session,
|
|
269
|
+
storageVersion: nextStorageVersion,
|
|
270
|
+
messages: migratedMessages,
|
|
271
|
+
updatedAt: new Date(),
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function normalizeMessageMediaRefs(
|
|
277
|
+
paths: StoragePaths,
|
|
278
|
+
message: Omit<ChatMessage, "id" | "createdAt"> | ChatMessage
|
|
279
|
+
): Promise<Omit<ChatMessage, "id" | "createdAt">> {
|
|
280
|
+
const next: Omit<ChatMessage, "id" | "createdAt"> = {
|
|
281
|
+
role: message.role,
|
|
282
|
+
content: message.content ?? "",
|
|
283
|
+
name: message.name,
|
|
284
|
+
tool_calls: message.tool_calls,
|
|
285
|
+
tool_call_id: message.tool_call_id,
|
|
286
|
+
images: message.images,
|
|
287
|
+
model: message.model,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// Normalize convenience image list
|
|
291
|
+
if (Array.isArray(next.images)) {
|
|
292
|
+
const normalizedImages: string[] = [];
|
|
293
|
+
for (const value of next.images) {
|
|
294
|
+
const cachedUrl = await normalizeImageRefToLocalUrl(paths, value);
|
|
295
|
+
normalizedImages.push(cachedUrl);
|
|
296
|
+
}
|
|
297
|
+
next.images = normalizedImages;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Normalize image_url parts in content
|
|
301
|
+
if (Array.isArray(next.content)) {
|
|
302
|
+
const normalizedContent = [];
|
|
303
|
+
for (const part of next.content) {
|
|
304
|
+
if (
|
|
305
|
+
part &&
|
|
306
|
+
typeof part === "object" &&
|
|
307
|
+
part.type === "image_url" &&
|
|
308
|
+
part.image_url &&
|
|
309
|
+
typeof part.image_url.url === "string"
|
|
310
|
+
) {
|
|
311
|
+
const normalizedUrl = await normalizeImageRefToLocalUrl(paths, part.image_url.url);
|
|
312
|
+
normalizedContent.push({
|
|
313
|
+
...part,
|
|
314
|
+
image_url: {
|
|
315
|
+
...part.image_url,
|
|
316
|
+
url: normalizedUrl,
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
} else {
|
|
320
|
+
normalizedContent.push(part);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
next.content = normalizedContent;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return next;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Normalize an image reference to a stable /admin/media/{hash} URL.
|
|
331
|
+
* If conversion fails, the ORIGINAL reference is returned so images are never
|
|
332
|
+
* silently dropped from sessions. A partial failure is far better than data loss.
|
|
333
|
+
*/
|
|
334
|
+
async function normalizeImageRefToLocalUrl(paths: StoragePaths, value: string): Promise<string> {
|
|
335
|
+
const trimmed = value.trim();
|
|
336
|
+
if (!trimmed) return value;
|
|
337
|
+
|
|
338
|
+
const localHash = extractLocalMediaHash(trimmed);
|
|
339
|
+
if (localHash) {
|
|
340
|
+
return `/admin/media/${localHash}`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (/^[a-f0-9]{16}$/i.test(trimmed)) {
|
|
344
|
+
return `/admin/media/${trimmed.toLowerCase()}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (trimmed.startsWith("data:image/") || trimmed.startsWith("data:audio/")) {
|
|
348
|
+
try {
|
|
349
|
+
const cached = await storeMedia(paths, trimmed);
|
|
350
|
+
return `/admin/media/${cached.hash}`;
|
|
351
|
+
} catch (err) {
|
|
352
|
+
console.error(`[waypoi] Failed to cache media ref (preserving original): ${(err as Error).message}`);
|
|
353
|
+
return value; // preserve — don't discard
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
358
|
+
try {
|
|
359
|
+
const response = await fetch(trimmed, { signal: AbortSignal.timeout(10_000) });
|
|
360
|
+
if (!response.ok) {
|
|
361
|
+
console.error(`[waypoi] Failed to fetch remote image (${response.status}), preserving URL: ${trimmed.slice(0, 80)}`);
|
|
362
|
+
return value; // preserve the URL — might resolve later
|
|
363
|
+
}
|
|
364
|
+
const contentType = response.headers.get("content-type") ?? undefined;
|
|
365
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
366
|
+
const cached = await storeMedia(paths, buffer, { mimeType: contentType });
|
|
367
|
+
return `/admin/media/${cached.hash}`;
|
|
368
|
+
} catch (err) {
|
|
369
|
+
console.error(`[waypoi] Failed to fetch/cache remote image (preserving URL): ${(err as Error).message}`);
|
|
370
|
+
return value; // preserve — might be temporarily unreachable
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Unknown format — preserve as-is
|
|
375
|
+
return value;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function extractLocalMediaHash(value: string): string | null {
|
|
379
|
+
if (value.startsWith("/")) {
|
|
380
|
+
const match = value.match(/^\/admin\/(?:media|images)\/([a-f0-9]{16})$/i);
|
|
381
|
+
return match ? match[1].toLowerCase() : null;
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
const parsed = new URL(value);
|
|
385
|
+
if (!["http:", "https:"].includes(parsed.protocol)) return null;
|
|
386
|
+
if (!["localhost", "127.0.0.1", "::1"].includes(parsed.hostname)) return null;
|
|
387
|
+
const match = parsed.pathname.match(/^\/admin\/(?:media|images)\/([a-f0-9]{16})$/i);
|
|
388
|
+
return match ? match[1].toLowerCase() : null;
|
|
389
|
+
} catch {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function extractMediaHashesFromSession(session: ChatSession): string[] {
|
|
395
|
+
const hashes = new Set<string>();
|
|
396
|
+
for (const message of session.messages) {
|
|
397
|
+
if (Array.isArray(message.images)) {
|
|
398
|
+
for (const imageRef of message.images) {
|
|
399
|
+
const hash = extractLocalMediaHash(imageRef);
|
|
400
|
+
if (hash) hashes.add(hash);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (Array.isArray(message.content)) {
|
|
404
|
+
for (const part of message.content) {
|
|
405
|
+
if (
|
|
406
|
+
part &&
|
|
407
|
+
typeof part === "object" &&
|
|
408
|
+
part.type === "image_url" &&
|
|
409
|
+
part.image_url &&
|
|
410
|
+
typeof part.image_url.url === "string"
|
|
411
|
+
) {
|
|
412
|
+
const hash = extractLocalMediaHash(part.image_url.url);
|
|
413
|
+
if (hash) hashes.add(hash);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return Array.from(hashes);
|
|
419
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { RequestStats, StatsAggregation } from "../types";
|
|
4
|
+
import { StoragePaths, ensureStorageDir } from "./files";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Stats Repository
|
|
8
|
+
*
|
|
9
|
+
* Manages request statistics with daily file rotation:
|
|
10
|
+
* - stats-YYYY-MM-DD.jsonl for each day
|
|
11
|
+
* - 7-day query window
|
|
12
|
+
* - 30-day retention with auto-cleanup
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface ExtendedStoragePaths extends StoragePaths {
|
|
16
|
+
statsDir: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveStatsDir(paths: StoragePaths): string {
|
|
20
|
+
return path.join(paths.baseDir, "stats");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatDate(date: Date): string {
|
|
24
|
+
return date.toISOString().split("T")[0];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getStatsFilePath(statsDir: string, date: Date): string {
|
|
28
|
+
return path.join(statsDir, `stats-${formatDate(date)}.jsonl`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function ensureStatsDir(paths: StoragePaths): Promise<void> {
|
|
32
|
+
const statsDir = resolveStatsDir(paths);
|
|
33
|
+
await fs.mkdir(statsDir, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function appendStats(paths: StoragePaths, stats: RequestStats): Promise<void> {
|
|
37
|
+
await ensureStatsDir(paths);
|
|
38
|
+
const statsDir = resolveStatsDir(paths);
|
|
39
|
+
const filePath = getStatsFilePath(statsDir, stats.timestamp);
|
|
40
|
+
const line = `${JSON.stringify(stats)}\n`;
|
|
41
|
+
await fs.appendFile(filePath, line, "utf8");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function readStatsForWindow(
|
|
45
|
+
paths: StoragePaths,
|
|
46
|
+
windowDays: number = 7
|
|
47
|
+
): Promise<RequestStats[]> {
|
|
48
|
+
await ensureStatsDir(paths);
|
|
49
|
+
const statsDir = resolveStatsDir(paths);
|
|
50
|
+
const stats: RequestStats[] = [];
|
|
51
|
+
const now = new Date();
|
|
52
|
+
const cutoff = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
|
|
53
|
+
|
|
54
|
+
// Read files for the window period
|
|
55
|
+
for (let i = 0; i <= windowDays; i++) {
|
|
56
|
+
const date = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
|
|
57
|
+
const filePath = getStatsFilePath(statsDir, date);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
61
|
+
const lines = raw.split("\n").filter((line) => line.trim().length > 0);
|
|
62
|
+
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
try {
|
|
65
|
+
const entry = JSON.parse(line) as RequestStats;
|
|
66
|
+
entry.timestamp = new Date(entry.timestamp);
|
|
67
|
+
if (entry.timestamp >= cutoff) {
|
|
68
|
+
stats.push(entry);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Skip malformed lines
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return stats.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function aggregateStats(
|
|
85
|
+
paths: StoragePaths,
|
|
86
|
+
windowMs: number = 7 * 24 * 60 * 60 * 1000
|
|
87
|
+
): Promise<StatsAggregation> {
|
|
88
|
+
const windowDays = Math.ceil(windowMs / (24 * 60 * 60 * 1000));
|
|
89
|
+
const stats = await readStatsForWindow(paths, windowDays);
|
|
90
|
+
const cutoff = Date.now() - windowMs;
|
|
91
|
+
const filtered = stats.filter((s) => s.timestamp.getTime() >= cutoff);
|
|
92
|
+
|
|
93
|
+
if (filtered.length === 0) {
|
|
94
|
+
return {
|
|
95
|
+
window: formatWindowString(windowMs),
|
|
96
|
+
total: 0,
|
|
97
|
+
success: 0,
|
|
98
|
+
errors: 0,
|
|
99
|
+
avgLatencyMs: null,
|
|
100
|
+
p50LatencyMs: null,
|
|
101
|
+
p95LatencyMs: null,
|
|
102
|
+
p99LatencyMs: null,
|
|
103
|
+
totalTokens: 0,
|
|
104
|
+
tokensPerHour: null,
|
|
105
|
+
byModel: {},
|
|
106
|
+
byEndpoint: {}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const latencies = filtered.map((s) => s.latencyMs).sort((a, b) => a - b);
|
|
111
|
+
const successCount = filtered.filter((s) => !s.errorType && s.statusCode >= 200 && s.statusCode < 400).length;
|
|
112
|
+
const errorCount = filtered.filter((s) => s.errorType || s.statusCode >= 400).length;
|
|
113
|
+
|
|
114
|
+
let totalTokens = 0;
|
|
115
|
+
const byModel: Record<string, { count: number; sumLatency: number; tokens: number }> = {};
|
|
116
|
+
const byEndpoint: Record<string, { count: number; sumLatency: number; tokens: number; errors: number; name: string }> = {};
|
|
117
|
+
|
|
118
|
+
for (const stat of filtered) {
|
|
119
|
+
const tokens = stat.totalTokens ?? 0;
|
|
120
|
+
totalTokens += tokens;
|
|
121
|
+
|
|
122
|
+
// Aggregate by model
|
|
123
|
+
if (stat.publicModel) {
|
|
124
|
+
if (!byModel[stat.publicModel]) {
|
|
125
|
+
byModel[stat.publicModel] = { count: 0, sumLatency: 0, tokens: 0 };
|
|
126
|
+
}
|
|
127
|
+
byModel[stat.publicModel].count += 1;
|
|
128
|
+
byModel[stat.publicModel].sumLatency += stat.latencyMs;
|
|
129
|
+
byModel[stat.publicModel].tokens += tokens;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Aggregate by endpoint
|
|
133
|
+
if (stat.endpointId) {
|
|
134
|
+
if (!byEndpoint[stat.endpointId]) {
|
|
135
|
+
byEndpoint[stat.endpointId] = { count: 0, sumLatency: 0, tokens: 0, errors: 0, name: stat.endpointName ?? "unknown" };
|
|
136
|
+
}
|
|
137
|
+
byEndpoint[stat.endpointId].count += 1;
|
|
138
|
+
byEndpoint[stat.endpointId].sumLatency += stat.latencyMs;
|
|
139
|
+
byEndpoint[stat.endpointId].tokens += tokens;
|
|
140
|
+
if (stat.errorType || stat.statusCode >= 400) {
|
|
141
|
+
byEndpoint[stat.endpointId].errors += 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Calculate percentiles
|
|
147
|
+
const p50 = percentile(latencies, 50);
|
|
148
|
+
const p95 = percentile(latencies, 95);
|
|
149
|
+
const p99 = percentile(latencies, 99);
|
|
150
|
+
const avgLatency = latencies.reduce((a, b) => a + b, 0) / latencies.length;
|
|
151
|
+
|
|
152
|
+
// Calculate tokens per hour
|
|
153
|
+
const windowHours = windowMs / (60 * 60 * 1000);
|
|
154
|
+
const tokensPerHour = windowHours > 0 ? totalTokens / windowHours : null;
|
|
155
|
+
|
|
156
|
+
// Transform aggregations to final format
|
|
157
|
+
const byModelFinal: Record<string, { count: number; avgLatencyMs: number; tokens: number }> = {};
|
|
158
|
+
for (const [model, data] of Object.entries(byModel)) {
|
|
159
|
+
byModelFinal[model] = {
|
|
160
|
+
count: data.count,
|
|
161
|
+
avgLatencyMs: Math.round(data.sumLatency / data.count),
|
|
162
|
+
tokens: data.tokens
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const byEndpointFinal: Record<string, { count: number; avgLatencyMs: number; tokens: number; errors: number }> = {};
|
|
167
|
+
for (const [id, data] of Object.entries(byEndpoint)) {
|
|
168
|
+
byEndpointFinal[id] = {
|
|
169
|
+
count: data.count,
|
|
170
|
+
avgLatencyMs: Math.round(data.sumLatency / data.count),
|
|
171
|
+
tokens: data.tokens,
|
|
172
|
+
errors: data.errors
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
window: formatWindowString(windowMs),
|
|
178
|
+
total: filtered.length,
|
|
179
|
+
success: successCount,
|
|
180
|
+
errors: errorCount,
|
|
181
|
+
avgLatencyMs: Math.round(avgLatency),
|
|
182
|
+
p50LatencyMs: p50,
|
|
183
|
+
p95LatencyMs: p95,
|
|
184
|
+
p99LatencyMs: p99,
|
|
185
|
+
totalTokens,
|
|
186
|
+
tokensPerHour: tokensPerHour !== null ? Math.round(tokensPerHour) : null,
|
|
187
|
+
byModel: byModelFinal,
|
|
188
|
+
byEndpoint: byEndpointFinal
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function percentile(sortedArr: number[], p: number): number | null {
|
|
193
|
+
if (sortedArr.length === 0) return null;
|
|
194
|
+
const index = Math.ceil((p / 100) * sortedArr.length) - 1;
|
|
195
|
+
return Math.round(sortedArr[Math.max(0, index)]);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function formatWindowString(ms: number): string {
|
|
199
|
+
const hours = ms / (60 * 60 * 1000);
|
|
200
|
+
if (hours < 24) return `${Math.round(hours)}h`;
|
|
201
|
+
return `${Math.round(hours / 24)}d`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Rotate stats files - delete files older than retentionDays
|
|
206
|
+
*/
|
|
207
|
+
export async function rotateStats(paths: StoragePaths, retentionDays: number = 30): Promise<number> {
|
|
208
|
+
await ensureStatsDir(paths);
|
|
209
|
+
const statsDir = resolveStatsDir(paths);
|
|
210
|
+
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
|
|
211
|
+
let deleted = 0;
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const files = await fs.readdir(statsDir);
|
|
215
|
+
|
|
216
|
+
for (const file of files) {
|
|
217
|
+
if (!file.startsWith("stats-") || !file.endsWith(".jsonl")) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Extract date from filename: stats-YYYY-MM-DD.jsonl
|
|
222
|
+
const match = file.match(/^stats-(\d{4}-\d{2}-\d{2})\.jsonl$/);
|
|
223
|
+
if (!match) continue;
|
|
224
|
+
|
|
225
|
+
const fileDate = new Date(match[1]);
|
|
226
|
+
if (fileDate < cutoff) {
|
|
227
|
+
await fs.unlink(path.join(statsDir, file));
|
|
228
|
+
deleted += 1;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return deleted;
|
|
238
|
+
}
|