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.
Files changed (260) hide show
  1. package/.github/instructions/ui.instructions.md +42 -0
  2. package/.github/workflows/ci.yml +35 -0
  3. package/.github/workflows/publish.yml +71 -0
  4. package/.github/workflows/release.yml +48 -0
  5. package/.playwright-mcp/console-2026-04-04T01-41-10-746Z.log +2 -0
  6. package/.playwright-mcp/console-2026-04-04T01-41-28-799Z.log +3 -0
  7. package/.playwright-mcp/console-2026-04-05T02-26-51-909Z.log +76 -0
  8. package/.playwright-mcp/page-2026-04-04T01-41-10-816Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-04T01-41-29-141Z.yml +77 -0
  10. package/.playwright-mcp/page-2026-04-04T01-41-42-633Z.yml +190 -0
  11. package/.playwright-mcp/page-2026-04-04T01-42-03-929Z.yml +262 -0
  12. package/.playwright-mcp/page-2026-04-04T02-12-54-813Z.yml +6 -0
  13. package/.playwright-mcp/page-2026-04-04T02-14-58-600Z.yml +190 -0
  14. package/.playwright-mcp/page-2026-04-04T02-15-03-923Z.yml +190 -0
  15. package/.playwright-mcp/page-2026-04-04T02-15-07-426Z.yml +190 -0
  16. package/.playwright-mcp/page-2026-04-04T02-15-25-729Z.yml +262 -0
  17. package/.playwright-mcp/page-2026-04-04T02-16-22-984Z.yml +262 -0
  18. package/.playwright-mcp/page-2026-04-04T02-17-00-599Z.yml +190 -0
  19. package/.playwright-mcp/page-2026-04-04T02-17-50-874Z.yml +190 -0
  20. package/.playwright-mcp/page-2026-04-05T02-26-55-570Z.yml +6 -0
  21. package/AGENTS.md +48 -0
  22. package/CHANGELOG.md +131 -0
  23. package/README.md +552 -0
  24. package/assets/agent-mode.png +0 -0
  25. package/assets/categorize.png +0 -0
  26. package/assets/dashboard.png +0 -0
  27. package/assets/endpoint-proxy.png +0 -0
  28. package/assets/icon.png +0 -0
  29. package/assets/mcp-generate-image.png +0 -0
  30. package/assets/mcp-understand-image.png +0 -0
  31. package/assets/peek-token-flow.png +0 -0
  32. package/assets/playground.png +0 -0
  33. package/assets/sankey.png +0 -0
  34. package/cli/index.ts +2805 -0
  35. package/cli/legacyRewrite.ts +108 -0
  36. package/cli/modelRef.ts +24 -0
  37. package/dist/cli/index.js +2536 -0
  38. package/dist/cli/legacyRewrite.js +92 -0
  39. package/dist/cli/modelRef.js +20 -0
  40. package/dist/src/benchmark/artifacts.js +131 -0
  41. package/dist/src/benchmark/capabilityClassifier.js +81 -0
  42. package/dist/src/benchmark/capabilityStore.js +144 -0
  43. package/dist/src/benchmark/config.js +238 -0
  44. package/dist/src/benchmark/gates.js +118 -0
  45. package/dist/src/benchmark/jobs.js +252 -0
  46. package/dist/src/benchmark/runner.js +1847 -0
  47. package/dist/src/benchmark/schema.js +353 -0
  48. package/dist/src/benchmark/suites.js +314 -0
  49. package/dist/src/benchmark/tinyQaDataset.js +422 -0
  50. package/dist/src/benchmark/types.js +25 -0
  51. package/dist/src/config.js +47 -0
  52. package/dist/src/index.js +178 -0
  53. package/dist/src/mcp/client.js +215 -0
  54. package/dist/src/mcp/discovery.js +226 -0
  55. package/dist/src/mcp/policy.js +65 -0
  56. package/dist/src/mcp/registry.js +129 -0
  57. package/dist/src/mcp/service.js +460 -0
  58. package/dist/src/middleware/auth.js +179 -0
  59. package/dist/src/middleware/requestCapture.js +192 -0
  60. package/dist/src/middleware/requestStats.js +118 -0
  61. package/dist/src/pools/builder.js +132 -0
  62. package/dist/src/pools/repository.js +69 -0
  63. package/dist/src/pools/scheduler.js +360 -0
  64. package/dist/src/pools/types.js +2 -0
  65. package/dist/src/protocols/adapters/dashscope.js +267 -0
  66. package/dist/src/protocols/adapters/inferenceV2.js +346 -0
  67. package/dist/src/protocols/adapters/openai.js +27 -0
  68. package/dist/src/protocols/registry.js +99 -0
  69. package/dist/src/protocols/types.js +2 -0
  70. package/dist/src/providers/health.js +153 -0
  71. package/dist/src/providers/importer.js +289 -0
  72. package/dist/src/providers/modelRegistry.js +313 -0
  73. package/dist/src/providers/repository.js +361 -0
  74. package/dist/src/providers/types.js +2 -0
  75. package/dist/src/routes/admin.js +531 -0
  76. package/dist/src/routes/audio.js +295 -0
  77. package/dist/src/routes/chat.js +240 -0
  78. package/dist/src/routes/embeddings.js +157 -0
  79. package/dist/src/routes/images.js +288 -0
  80. package/dist/src/routes/mcp.js +256 -0
  81. package/dist/src/routes/mcpService.js +100 -0
  82. package/dist/src/routes/models.js +48 -0
  83. package/dist/src/routes/responses.js +711 -0
  84. package/dist/src/routes/sessions.js +450 -0
  85. package/dist/src/routes/stats.js +270 -0
  86. package/dist/src/routes/ui.js +97 -0
  87. package/dist/src/routes/videos.js +107 -0
  88. package/dist/src/routing/router.js +338 -0
  89. package/dist/src/services/imageGeneration.js +280 -0
  90. package/dist/src/services/imageUnderstanding.js +352 -0
  91. package/dist/src/services/videoGeneration.js +79 -0
  92. package/dist/src/storage/captureRepository.js +1591 -0
  93. package/dist/src/storage/files.js +157 -0
  94. package/dist/src/storage/imageCache.js +346 -0
  95. package/dist/src/storage/repositories.js +388 -0
  96. package/dist/src/storage/sessionRepository.js +370 -0
  97. package/dist/src/storage/statsRepository.js +204 -0
  98. package/dist/src/transport/httpClient.js +126 -0
  99. package/dist/src/types.js +2 -0
  100. package/dist/src/utils/messageMedia.js +285 -0
  101. package/dist/src/utils/modelCapabilities.js +108 -0
  102. package/dist/src/utils/modelDiscovery.js +170 -0
  103. package/dist/src/version.js +5 -0
  104. package/dist/src/workers/captureRetention.js +25 -0
  105. package/dist/src/workers/configWatcher.js +91 -0
  106. package/dist/src/workers/healthChecker.js +21 -0
  107. package/dist/src/workers/statsRotation.js +41 -0
  108. package/docs/LLM/output_schema.md +312 -0
  109. package/docs/benchmark.md +208 -0
  110. package/docs/mcp-guidelines.md +125 -0
  111. package/docs/mcp-service.md +178 -0
  112. package/docs/opencode.md +86 -0
  113. package/docs/providers.md +79 -0
  114. package/examples/benchmark.config.yaml +28 -0
  115. package/examples/providers/alibaba-dashscope.yaml +88 -0
  116. package/examples/providers/alibaba-llm.yaml +64 -0
  117. package/examples/providers/alibaba-registry.yaml +7 -0
  118. package/examples/providers/inference-v2-ray.yaml +29 -0
  119. package/examples/scenarios/assets/omni-call-sample.wav +0 -0
  120. package/examples/scenarios/custom.jsonl +5 -0
  121. package/examples/scenarios/custom.yaml +40 -0
  122. package/model-form-v2.png +0 -0
  123. package/package.json +66 -0
  124. package/provider-form-v2.png +0 -0
  125. package/provider-form.png +0 -0
  126. package/scripts/manual-test.sh +11 -0
  127. package/scripts/version-from-git.js +23 -0
  128. package/src/benchmark/artifacts.ts +149 -0
  129. package/src/benchmark/capabilityClassifier.ts +99 -0
  130. package/src/benchmark/capabilityStore.ts +174 -0
  131. package/src/benchmark/config.ts +337 -0
  132. package/src/benchmark/gates.ts +164 -0
  133. package/src/benchmark/jobs.ts +312 -0
  134. package/src/benchmark/runner.ts +2519 -0
  135. package/src/benchmark/schema.ts +443 -0
  136. package/src/benchmark/suites.ts +323 -0
  137. package/src/benchmark/tinyQaDataset.ts +428 -0
  138. package/src/benchmark/types.ts +442 -0
  139. package/src/config.ts +44 -0
  140. package/src/index.ts +195 -0
  141. package/src/mcp/client.ts +305 -0
  142. package/src/mcp/discovery.ts +266 -0
  143. package/src/mcp/policy.ts +105 -0
  144. package/src/mcp/registry.ts +164 -0
  145. package/src/mcp/service.ts +611 -0
  146. package/src/middleware/auth.ts +251 -0
  147. package/src/middleware/requestCapture.ts +245 -0
  148. package/src/middleware/requestStats.ts +163 -0
  149. package/src/pools/builder.ts +159 -0
  150. package/src/pools/repository.ts +71 -0
  151. package/src/pools/scheduler.ts +425 -0
  152. package/src/pools/types.ts +117 -0
  153. package/src/protocols/adapters/dashscope.ts +335 -0
  154. package/src/protocols/adapters/inferenceV2.ts +428 -0
  155. package/src/protocols/adapters/openai.ts +32 -0
  156. package/src/protocols/registry.ts +117 -0
  157. package/src/protocols/types.ts +81 -0
  158. package/src/providers/health.ts +207 -0
  159. package/src/providers/importer.ts +402 -0
  160. package/src/providers/modelRegistry.ts +415 -0
  161. package/src/providers/repository.ts +439 -0
  162. package/src/providers/types.ts +113 -0
  163. package/src/routes/admin.ts +666 -0
  164. package/src/routes/audio.ts +372 -0
  165. package/src/routes/chat.ts +301 -0
  166. package/src/routes/embeddings.ts +197 -0
  167. package/src/routes/images.ts +356 -0
  168. package/src/routes/mcp.ts +320 -0
  169. package/src/routes/mcpService.ts +114 -0
  170. package/src/routes/models.ts +50 -0
  171. package/src/routes/responses.ts +872 -0
  172. package/src/routes/sessions.ts +558 -0
  173. package/src/routes/stats.ts +312 -0
  174. package/src/routes/ui.ts +96 -0
  175. package/src/routes/videos.ts +132 -0
  176. package/src/routing/router.ts +501 -0
  177. package/src/services/imageGeneration.ts +396 -0
  178. package/src/services/imageUnderstanding.ts +449 -0
  179. package/src/services/videoGeneration.ts +127 -0
  180. package/src/storage/captureRepository.ts +1835 -0
  181. package/src/storage/files.ts +178 -0
  182. package/src/storage/imageCache.ts +405 -0
  183. package/src/storage/repositories.ts +494 -0
  184. package/src/storage/sessionRepository.ts +419 -0
  185. package/src/storage/statsRepository.ts +238 -0
  186. package/src/transport/httpClient.ts +145 -0
  187. package/src/types.ts +322 -0
  188. package/src/utils/messageMedia.ts +293 -0
  189. package/src/utils/modelCapabilities.ts +161 -0
  190. package/src/utils/modelDiscovery.ts +203 -0
  191. package/src/workers/captureRetention.ts +25 -0
  192. package/src/workers/configWatcher.ts +115 -0
  193. package/src/workers/healthChecker.ts +22 -0
  194. package/src/workers/statsRotation.ts +49 -0
  195. package/tests/benchmarkAdminRoutes.test.ts +82 -0
  196. package/tests/benchmarkBasics.test.ts +116 -0
  197. package/tests/captureAdminRoutes.test.ts +420 -0
  198. package/tests/captureRepository.test.ts +797 -0
  199. package/tests/cliLegacyRewrite.test.ts +45 -0
  200. package/tests/imageGeneration.service.test.ts +107 -0
  201. package/tests/imageUnderstanding.service.test.ts +123 -0
  202. package/tests/mcpPolicy.test.ts +105 -0
  203. package/tests/mcpService.test.ts +1245 -0
  204. package/tests/modelRef.test.ts +23 -0
  205. package/tests/modelsRoutes.test.ts +154 -0
  206. package/tests/sessionMediaCache.test.ts +167 -0
  207. package/tests/statsRoutes.test.ts +323 -0
  208. package/tsconfig.json +15 -0
  209. package/ui/index.html +16 -0
  210. package/ui/package-lock.json +8521 -0
  211. package/ui/package.json +52 -0
  212. package/ui/postcss.config.js +6 -0
  213. package/ui/public/assets/apple-touch-icon.png +0 -0
  214. package/ui/public/assets/favicon-16.png +0 -0
  215. package/ui/public/assets/favicon-32.png +0 -0
  216. package/ui/public/assets/icon-192.png +0 -0
  217. package/ui/public/assets/icon-512.png +0 -0
  218. package/ui/src/App.tsx +27 -0
  219. package/ui/src/api/client.ts +1503 -0
  220. package/ui/src/components/EndpointUsageGuide.tsx +361 -0
  221. package/ui/src/components/Layout.tsx +124 -0
  222. package/ui/src/components/MessageContent.tsx +365 -0
  223. package/ui/src/components/ToolCallMessage.tsx +179 -0
  224. package/ui/src/components/ToolPicker.tsx +442 -0
  225. package/ui/src/components/messageContentParser.test.ts +41 -0
  226. package/ui/src/components/messageContentParser.ts +73 -0
  227. package/ui/src/components/thinkingPreview.test.ts +27 -0
  228. package/ui/src/components/thinkingPreview.ts +15 -0
  229. package/ui/src/components/toMermaidSankey.test.ts +78 -0
  230. package/ui/src/components/toMermaidSankey.ts +56 -0
  231. package/ui/src/components/ui/button.tsx +58 -0
  232. package/ui/src/components/ui/input.tsx +21 -0
  233. package/ui/src/components/ui/textarea.tsx +21 -0
  234. package/ui/src/lib/utils.ts +6 -0
  235. package/ui/src/main.tsx +9 -0
  236. package/ui/src/pages/AgentPlayground.tsx +2010 -0
  237. package/ui/src/pages/Benchmark.tsx +988 -0
  238. package/ui/src/pages/Dashboard.tsx +581 -0
  239. package/ui/src/pages/Peek.tsx +962 -0
  240. package/ui/src/pages/Settings.tsx +2013 -0
  241. package/ui/src/pages/agentPlaygroundPayload.test.ts +109 -0
  242. package/ui/src/pages/agentPlaygroundPayload.ts +97 -0
  243. package/ui/src/pages/agentThinkingContent.test.ts +50 -0
  244. package/ui/src/pages/agentThinkingContent.ts +57 -0
  245. package/ui/src/pages/dashboardTokenUsage.test.ts +66 -0
  246. package/ui/src/pages/dashboardTokenUsage.ts +36 -0
  247. package/ui/src/pages/imageUpload.test.ts +39 -0
  248. package/ui/src/pages/imageUpload.ts +71 -0
  249. package/ui/src/pages/peekFilters.test.ts +29 -0
  250. package/ui/src/pages/peekFilters.ts +13 -0
  251. package/ui/src/pages/peekMedia.test.ts +58 -0
  252. package/ui/src/pages/peekMedia.ts +148 -0
  253. package/ui/src/pages/sessionAutoTitle.test.ts +128 -0
  254. package/ui/src/pages/sessionAutoTitle.ts +106 -0
  255. package/ui/src/stores/settings.ts +58 -0
  256. package/ui/src/styles/globals.css +223 -0
  257. package/ui/src/vite-env.d.ts +8 -0
  258. package/ui/tailwind.config.js +106 -0
  259. package/ui/tsconfig.json +32 -0
  260. 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
+ }