@waifucave/discord-waifus 1.5.154

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 (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +230 -0
  3. package/THIRD_PARTY_NOTICES.md +26 -0
  4. package/assets/ocr/eng.traineddata +0 -0
  5. package/bin/waifus.mjs +36 -0
  6. package/dist/api/errors.d.ts +9 -0
  7. package/dist/api/errors.js +23 -0
  8. package/dist/api/errors.js.map +1 -0
  9. package/dist/api/server.d.ts +19 -0
  10. package/dist/api/server.js +1290 -0
  11. package/dist/api/server.js.map +1 -0
  12. package/dist/backend/logger.d.ts +20 -0
  13. package/dist/backend/logger.js +65 -0
  14. package/dist/backend/logger.js.map +1 -0
  15. package/dist/backend/migrations.d.ts +4 -0
  16. package/dist/backend/migrations.js +521 -0
  17. package/dist/backend/migrations.js.map +1 -0
  18. package/dist/backend/redaction.d.ts +1 -0
  19. package/dist/backend/redaction.js +22 -0
  20. package/dist/backend/redaction.js.map +1 -0
  21. package/dist/backend/runtime.d.ts +30 -0
  22. package/dist/backend/runtime.js +49 -0
  23. package/dist/backend/runtime.js.map +1 -0
  24. package/dist/backend/server.d.ts +18 -0
  25. package/dist/backend/server.js +581 -0
  26. package/dist/backend/server.js.map +1 -0
  27. package/dist/cli/commands.d.ts +35 -0
  28. package/dist/cli/commands.js +510 -0
  29. package/dist/cli/commands.js.map +1 -0
  30. package/dist/cli/main.d.ts +1 -0
  31. package/dist/cli/main.js +12 -0
  32. package/dist/cli/main.js.map +1 -0
  33. package/dist/cli/parser.d.ts +10 -0
  34. package/dist/cli/parser.js +64 -0
  35. package/dist/cli/parser.js.map +1 -0
  36. package/dist/config/appConfig.d.ts +3 -0
  37. package/dist/config/appConfig.js +27 -0
  38. package/dist/config/appConfig.js.map +1 -0
  39. package/dist/config/layout.d.ts +13 -0
  40. package/dist/config/layout.js +173 -0
  41. package/dist/config/layout.js.map +1 -0
  42. package/dist/config/paths.d.ts +5 -0
  43. package/dist/config/paths.js +20 -0
  44. package/dist/config/paths.js.map +1 -0
  45. package/dist/config/prebuiltWaifus.d.ts +3 -0
  46. package/dist/config/prebuiltWaifus.js +106 -0
  47. package/dist/config/prebuiltWaifus.js.map +1 -0
  48. package/dist/discord/client.d.ts +394 -0
  49. package/dist/discord/client.js +1196 -0
  50. package/dist/discord/client.js.map +1 -0
  51. package/dist/discord/contextBuilder.d.ts +15 -0
  52. package/dist/discord/contextBuilder.js +18 -0
  53. package/dist/discord/contextBuilder.js.map +1 -0
  54. package/dist/discord/intents.d.ts +5 -0
  55. package/dist/discord/intents.js +21 -0
  56. package/dist/discord/intents.js.map +1 -0
  57. package/dist/discord/memberCache.d.ts +12 -0
  58. package/dist/discord/memberCache.js +55 -0
  59. package/dist/discord/memberCache.js.map +1 -0
  60. package/dist/discord/normalization.d.ts +34 -0
  61. package/dist/discord/normalization.js +330 -0
  62. package/dist/discord/normalization.js.map +1 -0
  63. package/dist/discord/rateLimit.d.ts +9 -0
  64. package/dist/discord/rateLimit.js +11 -0
  65. package/dist/discord/rateLimit.js.map +1 -0
  66. package/dist/orchestration/context.d.ts +85 -0
  67. package/dist/orchestration/context.js +200 -0
  68. package/dist/orchestration/context.js.map +1 -0
  69. package/dist/orchestration/decisions.d.ts +52 -0
  70. package/dist/orchestration/decisions.js +61 -0
  71. package/dist/orchestration/decisions.js.map +1 -0
  72. package/dist/orchestration/messageSplit.d.ts +6 -0
  73. package/dist/orchestration/messageSplit.js +118 -0
  74. package/dist/orchestration/messageSplit.js.map +1 -0
  75. package/dist/orchestration/ocr.d.ts +43 -0
  76. package/dist/orchestration/ocr.js +504 -0
  77. package/dist/orchestration/ocr.js.map +1 -0
  78. package/dist/orchestration/ocrPackages.d.ts +24 -0
  79. package/dist/orchestration/ocrPackages.js +85 -0
  80. package/dist/orchestration/ocrPackages.js.map +1 -0
  81. package/dist/orchestration/promptBlocks.d.ts +28 -0
  82. package/dist/orchestration/promptBlocks.js +232 -0
  83. package/dist/orchestration/promptBlocks.js.map +1 -0
  84. package/dist/orchestration/replyQuote.d.ts +7 -0
  85. package/dist/orchestration/replyQuote.js +198 -0
  86. package/dist/orchestration/replyQuote.js.map +1 -0
  87. package/dist/orchestration/reviewer.d.ts +5 -0
  88. package/dist/orchestration/reviewer.js +5 -0
  89. package/dist/orchestration/reviewer.js.map +1 -0
  90. package/dist/orchestration/runtime.d.ts +152 -0
  91. package/dist/orchestration/runtime.js +3535 -0
  92. package/dist/orchestration/runtime.js.map +1 -0
  93. package/dist/orchestration/session.d.ts +30 -0
  94. package/dist/orchestration/session.js +41 -0
  95. package/dist/orchestration/session.js.map +1 -0
  96. package/dist/orchestration/stageManager.d.ts +42 -0
  97. package/dist/orchestration/stageManager.js +50 -0
  98. package/dist/orchestration/stageManager.js.map +1 -0
  99. package/dist/providers/catalog.d.ts +6 -0
  100. package/dist/providers/catalog.js +212 -0
  101. package/dist/providers/catalog.js.map +1 -0
  102. package/dist/providers/pipelines.d.ts +13 -0
  103. package/dist/providers/pipelines.js +2388 -0
  104. package/dist/providers/pipelines.js.map +1 -0
  105. package/dist/providers/types.d.ts +91 -0
  106. package/dist/providers/types.js +2 -0
  107. package/dist/providers/types.js.map +1 -0
  108. package/dist/shared/queryLog.d.ts +39 -0
  109. package/dist/shared/queryLog.js +80 -0
  110. package/dist/shared/queryLog.js.map +1 -0
  111. package/dist/shared/schemas/common.d.ts +21 -0
  112. package/dist/shared/schemas/common.js +27 -0
  113. package/dist/shared/schemas/common.js.map +1 -0
  114. package/dist/shared/schemas/config.d.ts +57 -0
  115. package/dist/shared/schemas/config.js +49 -0
  116. package/dist/shared/schemas/config.js.map +1 -0
  117. package/dist/shared/schemas/domain.d.ts +825 -0
  118. package/dist/shared/schemas/domain.js +444 -0
  119. package/dist/shared/schemas/domain.js.map +1 -0
  120. package/dist/storage/atomic.d.ts +6 -0
  121. package/dist/storage/atomic.js +60 -0
  122. package/dist/storage/atomic.js.map +1 -0
  123. package/dist/storage/errors.d.ts +12 -0
  124. package/dist/storage/errors.js +23 -0
  125. package/dist/storage/errors.js.map +1 -0
  126. package/dist/storage/locks.d.ts +9 -0
  127. package/dist/storage/locks.js +42 -0
  128. package/dist/storage/locks.js.map +1 -0
  129. package/dist/storage/storageService.d.ts +25 -0
  130. package/dist/storage/storageService.js +66 -0
  131. package/dist/storage/storageService.js.map +1 -0
  132. package/dist-frontend/assets/index-B2bB3_2r.js +39 -0
  133. package/dist-frontend/assets/index-B2bB3_2r.js.map +1 -0
  134. package/dist-frontend/assets/index-Dj1L8Px-.css +1 -0
  135. package/dist-frontend/index.html +14 -0
  136. package/docs/api.md +162 -0
  137. package/docs/old-orchestrator-prompt.md +123 -0
  138. package/package.json +81 -0
@@ -0,0 +1,1290 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { access, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import fastify from "fastify";
6
+ import { z } from "zod";
7
+ import { loadAppConfig, saveAppConfig } from "../config/appConfig.js";
8
+ import { resolveDataPath, userDataPath } from "../config/paths.js";
9
+ import { createLogger } from "../backend/logger.js";
10
+ import { clearOcrArtifacts } from "../orchestration/ocr.js";
11
+ import { listModels, listProviders } from "../providers/catalog.js";
12
+ import { AgentConfigSchema, DiscordBotsFileSchema, GuildEmojisFileSchema, GuildMembersFileSchema, GuildRolesFileSchema, MemoryStoreSchema, ShortTermMemoryStoreSchema, OrchestratorHistoryFileSchema, ProviderCredentialsFileSchema, ReviewerHistoryFileSchema, ProviderIdSchema, ServerConfigSchema, StageManagerHistoryFileSchema, WaifuConfigSchema, createEmptyRevisionedFile } from "../shared/schemas/domain.js";
13
+ import { AppConfigSchema } from "../shared/schemas/config.js";
14
+ import { createRevisionedBase, nowIso } from "../shared/schemas/common.js";
15
+ import { StorageConflictError } from "../storage/errors.js";
16
+ import { StorageService } from "../storage/storageService.js";
17
+ import { recentQueries, recentReplies, subscribeQueries, subscribeReplies } from "../shared/queryLog.js";
18
+ import { ApiError, badRequest, conflict, notFound, preconditionRequired } from "./errors.js";
19
+ import { mergeConfiguredBotsIntoMembers } from "../discord/memberCache.js";
20
+ const ProviderCredentialsBodySchema = z.object({
21
+ apiKey: z.string().min(1),
22
+ label: z.string().optional(),
23
+ revision: z.number().int().nonnegative().optional()
24
+ });
25
+ const CreateWaifuBodySchema = WaifuConfigSchema.omit({
26
+ schemaVersion: true,
27
+ revision: true,
28
+ updatedAt: true
29
+ })
30
+ .partial({ id: true, enabled: true, persona: true, contextWindow: true, generation: true })
31
+ .required({ name: true, displayName: true });
32
+ const UpdateWaifuBodySchema = WaifuConfigSchema.partial().extend({
33
+ revision: z.number().int().nonnegative().optional()
34
+ });
35
+ const ServerConfigBodySchema = ServerConfigSchema.partial().extend({
36
+ revision: z.number().int().nonnegative().optional()
37
+ });
38
+ const ChannelConfigBodySchema = z.object({
39
+ revision: z.number().int().nonnegative().optional(),
40
+ name: z.string().optional(),
41
+ enabled: z.boolean().default(true),
42
+ enabledWaifuIds: z.array(z.string()).optional()
43
+ });
44
+ const CreateMemoryBodySchema = z.object({
45
+ revision: z.number().int().nonnegative().optional(),
46
+ waifuId: z.string().min(1),
47
+ guildId: z.string().min(1),
48
+ scope: z.literal("guild").optional(),
49
+ content: z.string().min(1),
50
+ importance: z.union([
51
+ z.literal(1),
52
+ z.literal(2),
53
+ z.literal(3),
54
+ z.literal(4),
55
+ z.literal(5)
56
+ ]),
57
+ permanent: z.boolean().default(false),
58
+ sourceMessageIds: z.array(z.string()).default([])
59
+ });
60
+ const UpdateMemoryBodySchema = CreateMemoryBodySchema.partial().extend({
61
+ revision: z.number().int().nonnegative().optional(),
62
+ permanent: z.boolean().optional(),
63
+ status: z.enum(["active", "archived"]).optional()
64
+ });
65
+ const UpdateShortTermMemoryBodySchema = z.object({
66
+ revision: z.number().int().nonnegative().optional(),
67
+ content: z.string().min(1).optional(),
68
+ waifuId: z.string().min(1).optional()
69
+ });
70
+ const AgentConfigBodySchema = AgentConfigSchema.partial().extend({
71
+ revision: z.number().int().nonnegative().optional()
72
+ });
73
+ const DiscordBotsBodySchema = DiscordBotsFileSchema.partial().extend({
74
+ revision: z.number().int().nonnegative().optional()
75
+ });
76
+ const DiscordApiErrorSchema = z.object({
77
+ message: z.string().optional(),
78
+ code: z.number().optional()
79
+ });
80
+ const RuntimeTriggerBodySchema = z.object({
81
+ guildId: z.string().min(1).optional(),
82
+ channelId: z.string().min(1).optional()
83
+ }).optional();
84
+ const ASSET_EXT_BY_MIME = {
85
+ "image/png": "png",
86
+ "image/jpeg": "jpg",
87
+ "image/webp": "webp",
88
+ "image/gif": "gif"
89
+ };
90
+ export async function createApiServer(options) {
91
+ const storage = options.storage ?? new StorageService(options.dataRoot);
92
+ const logger = options.logger ?? createLogger({ dataRoot: options.dataRoot });
93
+ const app = fastify({ logger: false });
94
+ app.addContentTypeParser(/^image\/(png|jpeg|webp|gif)$/u, { parseAs: "buffer" }, (_request, body, done) => {
95
+ done(null, body);
96
+ });
97
+ app.addContentTypeParser("application/octet-stream", { parseAs: "buffer" }, (_request, body, done) => {
98
+ done(null, body);
99
+ });
100
+ app.setErrorHandler((error, _request, reply) => {
101
+ if (error instanceof StorageConflictError) {
102
+ void reply.status(409).send({
103
+ error: "Conflict",
104
+ message: error.message,
105
+ latest: error.latest
106
+ });
107
+ return;
108
+ }
109
+ if (error instanceof ApiError) {
110
+ void reply.status(error.statusCode).send({
111
+ error: error.name,
112
+ message: error.message,
113
+ details: error.details
114
+ });
115
+ return;
116
+ }
117
+ if (error instanceof z.ZodError) {
118
+ void reply.status(400).send({
119
+ error: "ValidationError",
120
+ issues: error.issues
121
+ });
122
+ return;
123
+ }
124
+ const unknownError = error;
125
+ logger.error("Unhandled API error", { message: unknownError.message, stack: unknownError.stack });
126
+ void reply.status(500).send({ error: "InternalServerError" });
127
+ });
128
+ app.get("/", async (request, reply) => {
129
+ if (await tryServeFrontend(request, reply, options.dataRoot, "/")) {
130
+ return reply;
131
+ }
132
+ return {
133
+ name: "@waifucave/discord-waifus",
134
+ api: "/api/health"
135
+ };
136
+ });
137
+ app.get("/api/health", async () => ({
138
+ ok: true,
139
+ service: "discord-waifus",
140
+ time: new Date().toISOString()
141
+ }));
142
+ app.get("/api/status", async () => ({
143
+ running: true,
144
+ paused: options.runtime.paused,
145
+ httpUrl: `http://127.0.0.1:${options.runtime.port}`,
146
+ dataRoot: options.dataRoot,
147
+ discord: options.runtime.discord,
148
+ queues: options.runtime.queues
149
+ }));
150
+ app.get("/api/runtime", async () => options.runtime);
151
+ app.get("/api/config", async () => loadAppConfig(options.dataRoot));
152
+ app.put("/api/config", async (request) => {
153
+ const config = AppConfigSchema.parse(request.body);
154
+ const saved = await saveAppConfig(options.dataRoot, config);
155
+ options.runtime.paused = saved.runtime.paused;
156
+ await options.runtimeControl?.reload("config-updated");
157
+ return saved;
158
+ });
159
+ app.post("/api/cache/ocr/clear", async () => {
160
+ await clearOcrArtifacts(options.dataRoot);
161
+ return {
162
+ accepted: true,
163
+ message: "OCR cache and temporary OCR files cleared."
164
+ };
165
+ });
166
+ app.get("/api/discord-bots", async () => sanitizeDiscordBots(await readDiscordBots(storage)));
167
+ app.put("/api/discord-bots", async (request) => {
168
+ const body = DiscordBotsBodySchema.parse(request.body);
169
+ const saved = await storage.updateRevisionedJson({
170
+ resourceKey: "discord-bots",
171
+ relativePath: "user/discord-bots.json",
172
+ schema: DiscordBotsFileSchema,
173
+ fallback: emptyDiscordBots(),
174
+ expectedRevision: expectedRevision(request, body),
175
+ transform: (current) => {
176
+ const { revision: _revision, schemaVersion: _schemaVersion, updatedAt: _updatedAt, ...patch } = body;
177
+ return mergeDiscordBots(current, patch);
178
+ }
179
+ });
180
+ await options.runtimeControl?.reload("discord-bots-updated");
181
+ return sanitizeDiscordBots(saved);
182
+ });
183
+ app.get("/api/orchestrator/config", async () => readAgentConfig(storage, "orchestrator"));
184
+ app.put("/api/orchestrator/config", async (request) => {
185
+ const body = AgentConfigBodySchema.parse(request.body);
186
+ return updateAgentConfig(storage, request, "orchestrator", body, 20);
187
+ });
188
+ app.get("/api/orchestrator/history", async () => readOrchestratorHistory(storage));
189
+ app.get("/api/stage-manager/config", async () => readAgentConfig(storage, "stage-manager"));
190
+ app.put("/api/stage-manager/config", async (request) => {
191
+ const body = AgentConfigBodySchema.parse(request.body);
192
+ return updateAgentConfig(storage, request, "stage-manager", body, 80);
193
+ });
194
+ app.get("/api/stage-manager/history", async () => readStageManagerHistory(storage));
195
+ app.get("/api/reviewer/config", async () => readAgentConfig(storage, "reviewer"));
196
+ app.put("/api/reviewer/config", async (request) => {
197
+ const body = AgentConfigBodySchema.parse(request.body);
198
+ return updateAgentConfig(storage, request, "reviewer", body, 20);
199
+ });
200
+ app.get("/api/reviewer/history", async () => readReviewerHistory(storage));
201
+ app.get("/api/providers", async () => {
202
+ const credentials = await readProviderCredentials(storage);
203
+ return {
204
+ revision: credentials.revision,
205
+ updatedAt: credentials.updatedAt,
206
+ providers: listProviders().map((provider) => {
207
+ const saved = credentials.providers[provider.id];
208
+ return {
209
+ ...provider,
210
+ credentials: saved
211
+ ? {
212
+ configured: true,
213
+ label: saved.label,
214
+ updatedAt: saved.updatedAt,
215
+ keyHint: keyHint(saved.apiKey)
216
+ }
217
+ : {
218
+ configured: false
219
+ }
220
+ };
221
+ })
222
+ };
223
+ });
224
+ app.put("/api/providers/:providerId/credentials", async (request) => {
225
+ const { providerId } = z.object({ providerId: ProviderIdSchema }).parse(request.params);
226
+ const body = ProviderCredentialsBodySchema.parse(request.body);
227
+ const now = nowIso();
228
+ const updated = await storage.updateRevisionedJson({
229
+ resourceKey: "providers",
230
+ relativePath: "user/providers.json",
231
+ schema: ProviderCredentialsFileSchema,
232
+ fallback: emptyProviderCredentials(),
233
+ expectedRevision: expectedRevision(request, body),
234
+ transform: (current) => ({
235
+ ...current,
236
+ providers: {
237
+ ...current.providers,
238
+ [providerId]: {
239
+ providerId,
240
+ apiKey: body.apiKey,
241
+ label: body.label,
242
+ createdAt: current.providers[providerId]?.createdAt ?? now,
243
+ updatedAt: now
244
+ }
245
+ }
246
+ })
247
+ });
248
+ return providerCredentialsResponse(updated, providerId);
249
+ });
250
+ app.get("/api/models", async () => ({ models: listModels() }));
251
+ app.get("/api/waifus", async () => ({ waifus: await listWaifus(storage) }));
252
+ app.post("/api/waifus", async (request, reply) => {
253
+ const body = CreateWaifuBodySchema.parse(request.body);
254
+ const id = body.id ?? slugId(body.name);
255
+ assertSafeId(id);
256
+ const relativePath = waifuRelativePath(id);
257
+ if (await exists(resolveDataPath(options.dataRoot, relativePath))) {
258
+ throw conflict(`Waifu ${id} already exists.`);
259
+ }
260
+ const waifu = WaifuConfigSchema.parse({
261
+ ...createRevisionedBase(),
262
+ ...body,
263
+ id
264
+ });
265
+ const saved = await storage.writeJson(`waifu:${id}`, relativePath, WaifuConfigSchema, waifu);
266
+ return reply.status(201).send(saved);
267
+ });
268
+ app.get("/api/waifus/:waifuId", async (request) => {
269
+ const { waifuId } = parseIdParam(request.params, "waifuId");
270
+ return readWaifu(storage, waifuId);
271
+ });
272
+ app.put("/api/waifus/:waifuId", async (request) => {
273
+ const { waifuId } = parseIdParam(request.params, "waifuId");
274
+ const body = UpdateWaifuBodySchema.parse(request.body);
275
+ requireRevision(request, body);
276
+ return storage.updateRevisionedJson({
277
+ resourceKey: `waifu:${waifuId}`,
278
+ relativePath: waifuRelativePath(waifuId),
279
+ schema: WaifuConfigSchema,
280
+ fallback: await readWaifu(storage, waifuId),
281
+ expectedRevision: expectedRevision(request, body),
282
+ transform: (current) => {
283
+ const { revision: _revision, schemaVersion: _schemaVersion, updatedAt: _updatedAt, id: _id, ...patch } = body;
284
+ return {
285
+ ...current,
286
+ ...patch,
287
+ id: current.id
288
+ };
289
+ }
290
+ });
291
+ });
292
+ app.delete("/api/waifus/:waifuId", async (request, reply) => {
293
+ const { waifuId } = parseIdParam(request.params, "waifuId");
294
+ const existing = await readWaifu(storage, waifuId);
295
+ const body = z.object({ revision: z.number().int().nonnegative().optional() }).optional().parse(request.body);
296
+ const revision = expectedRevision(request, body);
297
+ if (revision === undefined) {
298
+ throw preconditionRequired("DELETE /api/waifus/:waifuId requires revision or If-Match.");
299
+ }
300
+ if (existing.revision !== revision) {
301
+ throw new StorageConflictError("Record has changed since it was read.", existing);
302
+ }
303
+ await storage.withLocks([`waifu:${waifuId}`], () => rm(resolveDataPath(options.dataRoot, "user", "waifus", waifuId), {
304
+ recursive: true,
305
+ force: true
306
+ }));
307
+ return reply.status(204).send();
308
+ });
309
+ app.post("/api/waifus/:waifuId/assets/pfp", async (request, reply) => {
310
+ const { waifuId } = parseIdParam(request.params, "waifuId");
311
+ await readWaifu(storage, waifuId);
312
+ return saveWaifuAsset(options.dataRoot, request, reply, waifuId, "pfp");
313
+ });
314
+ app.post("/api/waifus/:waifuId/assets/banner", async (request, reply) => {
315
+ const { waifuId } = parseIdParam(request.params, "waifuId");
316
+ await readWaifu(storage, waifuId);
317
+ return saveWaifuAsset(options.dataRoot, request, reply, waifuId, "banner");
318
+ });
319
+ app.get("/api/servers", async () => ({ servers: await listServers(storage) }));
320
+ app.put("/api/servers/:guildId", async (request) => {
321
+ const { guildId } = parseIdParam(request.params, "guildId");
322
+ const body = ServerConfigBodySchema.parse(request.body);
323
+ const fallback = ServerConfigSchema.parse({
324
+ ...createRevisionedBase(),
325
+ guildId
326
+ });
327
+ return storage.updateRevisionedJson({
328
+ resourceKey: `server:${guildId}`,
329
+ relativePath: serverRelativePath(guildId),
330
+ schema: ServerConfigSchema,
331
+ fallback,
332
+ expectedRevision: expectedRevision(request, body),
333
+ transform: (current) => {
334
+ const { revision: _revision, schemaVersion: _schemaVersion, updatedAt: _updatedAt, guildId: _guildId, ...patch } = body;
335
+ return {
336
+ ...current,
337
+ ...patch,
338
+ guildId
339
+ };
340
+ }
341
+ });
342
+ });
343
+ app.get("/api/servers/:guildId/members", async (request) => {
344
+ const { guildId } = parseIdParam(request.params, "guildId");
345
+ return readMembers(storage, guildId);
346
+ });
347
+ app.post("/api/servers/:guildId/members/refresh", async (request, reply) => {
348
+ const { guildId } = parseIdParam(request.params, "guildId");
349
+ const refreshed = await refreshDiscordMembers(storage, guildId);
350
+ return reply.status(202).send(refreshed);
351
+ });
352
+ app.get("/api/servers/:guildId/emojis", async (request) => {
353
+ const { guildId } = parseIdParam(request.params, "guildId");
354
+ return readEmojis(storage, guildId);
355
+ });
356
+ app.post("/api/servers/:guildId/emojis/refresh", async (request, reply) => {
357
+ const { guildId } = parseIdParam(request.params, "guildId");
358
+ const refreshed = await refreshDiscordEmojis(storage, guildId);
359
+ return reply.status(202).send(refreshed);
360
+ });
361
+ app.get("/api/servers/:guildId/roles", async (request) => {
362
+ const { guildId } = parseIdParam(request.params, "guildId");
363
+ return readRoles(storage, guildId);
364
+ });
365
+ app.post("/api/servers/:guildId/roles/refresh", async (request, reply) => {
366
+ const { guildId } = parseIdParam(request.params, "guildId");
367
+ const refreshed = await refreshDiscordRoles(storage, guildId);
368
+ return reply.status(202).send(refreshed);
369
+ });
370
+ app.put("/api/servers/:guildId/channels/:channelId", async (request) => {
371
+ const { guildId, channelId } = z
372
+ .object({ guildId: z.string().min(1), channelId: z.string().min(1) })
373
+ .parse(request.params);
374
+ assertSafeId(guildId);
375
+ assertSafeId(channelId);
376
+ const body = ChannelConfigBodySchema.parse(request.body);
377
+ return storage.updateRevisionedJson({
378
+ resourceKey: `server:${guildId}`,
379
+ relativePath: serverRelativePath(guildId),
380
+ schema: ServerConfigSchema,
381
+ fallback: ServerConfigSchema.parse({
382
+ ...createRevisionedBase(),
383
+ guildId
384
+ }),
385
+ expectedRevision: expectedRevision(request, body),
386
+ transform: (current) => ({
387
+ ...current,
388
+ channels: {
389
+ ...current.channels,
390
+ [channelId]: {
391
+ channelId,
392
+ name: body.name ?? current.channels[channelId]?.name,
393
+ enabled: body.enabled,
394
+ enabledWaifuIds: body.enabledWaifuIds ?? current.channels[channelId]?.enabledWaifuIds ?? []
395
+ }
396
+ }
397
+ })
398
+ });
399
+ });
400
+ app.get("/api/memories", async () => readMemoryStore(storage));
401
+ app.post("/api/memories", async (request, reply) => {
402
+ const body = CreateMemoryBodySchema.parse(request.body);
403
+ const now = nowIso();
404
+ const updated = await storage.updateRevisionedJson({
405
+ resourceKey: "memories:global",
406
+ relativePath: "user/memories.json",
407
+ schema: MemoryStoreSchema,
408
+ fallback: emptyMemoryStore(),
409
+ expectedRevision: expectedRevision(request, body),
410
+ transform: (current) => ({
411
+ ...current,
412
+ memories: [
413
+ ...current.memories,
414
+ {
415
+ id: randomUUID(),
416
+ waifuId: body.waifuId,
417
+ scope: "guild",
418
+ guildId: body.guildId,
419
+ content: body.content,
420
+ importance: body.importance,
421
+ permanent: body.permanent,
422
+ createdAt: now,
423
+ updatedAt: now,
424
+ sourceMessageIds: body.sourceMessageIds,
425
+ status: "active"
426
+ }
427
+ ]
428
+ })
429
+ });
430
+ return reply.status(201).send(updated);
431
+ });
432
+ app.put("/api/memories/:memoryId", async (request) => {
433
+ const { memoryId } = parseIdParam(request.params, "memoryId");
434
+ const body = UpdateMemoryBodySchema.parse(request.body);
435
+ requireRevision(request, body);
436
+ return storage.updateRevisionedJson({
437
+ resourceKey: "memories:global",
438
+ relativePath: "user/memories.json",
439
+ schema: MemoryStoreSchema,
440
+ fallback: emptyMemoryStore(),
441
+ expectedRevision: expectedRevision(request, body),
442
+ transform: (current) => updateMemoryOrThrow(current, memoryId, body)
443
+ });
444
+ });
445
+ app.delete("/api/memories/:memoryId", async (request) => {
446
+ const { memoryId } = parseIdParam(request.params, "memoryId");
447
+ const body = z.object({ revision: z.number().int().nonnegative().optional() }).optional().parse(request.body);
448
+ const revision = expectedRevision(request, body);
449
+ if (revision === undefined) {
450
+ throw preconditionRequired("DELETE /api/memories/:memoryId requires revision or If-Match.");
451
+ }
452
+ return storage.updateRevisionedJson({
453
+ resourceKey: "memories:global",
454
+ relativePath: "user/memories.json",
455
+ schema: MemoryStoreSchema,
456
+ fallback: emptyMemoryStore(),
457
+ expectedRevision: revision,
458
+ transform: (current) => ({
459
+ ...current,
460
+ memories: current.memories.filter((memory) => memory.id !== memoryId)
461
+ })
462
+ });
463
+ });
464
+ app.get("/api/short-term-memories", async () => {
465
+ const store = await readShortTermMemoryStore(storage);
466
+ const now = Date.now();
467
+ return {
468
+ ...store,
469
+ entries: store.entries.filter((entry) => Date.parse(entry.expiresAt) > now)
470
+ };
471
+ });
472
+ app.put("/api/short-term-memories/:entryId", async (request) => {
473
+ const { entryId } = parseIdParam(request.params, "entryId");
474
+ const body = UpdateShortTermMemoryBodySchema.parse(request.body);
475
+ requireRevision(request, body);
476
+ return storage.updateRevisionedJson({
477
+ resourceKey: "short-term-memories:global",
478
+ relativePath: "user/short-term-memories.json",
479
+ schema: ShortTermMemoryStoreSchema,
480
+ fallback: emptyShortTermMemoryStore(),
481
+ expectedRevision: expectedRevision(request, body),
482
+ transform: (current) => updateShortTermMemoryOrThrow(current, entryId, body)
483
+ });
484
+ });
485
+ app.delete("/api/short-term-memories/:entryId", async (request) => {
486
+ const { entryId } = parseIdParam(request.params, "entryId");
487
+ const body = z.object({ revision: z.number().int().nonnegative().optional() }).optional().parse(request.body);
488
+ const revision = expectedRevision(request, body);
489
+ if (revision === undefined) {
490
+ throw preconditionRequired("DELETE /api/short-term-memories/:entryId requires revision or If-Match.");
491
+ }
492
+ return storage.updateRevisionedJson({
493
+ resourceKey: "short-term-memories:global",
494
+ relativePath: "user/short-term-memories.json",
495
+ schema: ShortTermMemoryStoreSchema,
496
+ fallback: emptyShortTermMemoryStore(),
497
+ expectedRevision: revision,
498
+ transform: (current) => ({
499
+ ...current,
500
+ entries: current.entries.filter((entry) => entry.id !== entryId)
501
+ })
502
+ });
503
+ });
504
+ app.post("/api/runtime/pause", async () => {
505
+ options.runtime.paused = true;
506
+ await (options.runtimeControl?.pause() ?? options.runtimeOrchestrator?.pause());
507
+ options.runtime.updatedAt = nowIso();
508
+ return options.runtime;
509
+ });
510
+ app.post("/api/runtime/resume", async () => {
511
+ options.runtime.paused = false;
512
+ await options.runtimeControl?.resume();
513
+ options.runtime.updatedAt = nowIso();
514
+ return options.runtime;
515
+ });
516
+ app.post("/api/runtime/reload", async () => {
517
+ await options.runtimeControl?.reload("manual-api");
518
+ return {
519
+ accepted: true,
520
+ message: "Runtime reload applied. Discord clients and runtime orchestration were rebuilt without restarting HTTP."
521
+ };
522
+ });
523
+ app.post("/api/runtime/trigger/orchestrator", async (request) => {
524
+ const body = RuntimeTriggerBodySchema.parse(request.body);
525
+ const runtimeOrchestrator = options.runtimeControl?.getOrchestrator() ?? options.runtimeOrchestrator;
526
+ if (body?.guildId && body.channelId && runtimeOrchestrator) {
527
+ void runtimeOrchestrator.triggerChannel(body.guildId, body.channelId, "manual-api");
528
+ return {
529
+ accepted: true,
530
+ message: "Orchestrator trigger accepted for the requested channel."
531
+ };
532
+ }
533
+ const history = await appendOrchestratorHistory(storage, {
534
+ id: randomUUID(),
535
+ action: "no_reply",
536
+ respondingWaifus: [],
537
+ retriggerAfterSeconds: 180,
538
+ reasoning: "Manual trigger accepted without a target guild/channel.",
539
+ status: "completed",
540
+ waifuMessageIds: [],
541
+ responderOutcomes: [],
542
+ createdAt: nowIso()
543
+ });
544
+ return {
545
+ accepted: true,
546
+ message: "Orchestrator trigger accepted and recorded in history.",
547
+ history
548
+ };
549
+ });
550
+ app.post("/api/runtime/trigger/stage-manager", async (request) => {
551
+ const body = RuntimeTriggerBodySchema.parse(request.body);
552
+ const runtimeOrchestrator = options.runtimeControl?.getOrchestrator() ?? options.runtimeOrchestrator;
553
+ if (body?.guildId && body.channelId && runtimeOrchestrator) {
554
+ void runtimeOrchestrator.triggerStageManager(body.guildId, body.channelId);
555
+ return {
556
+ accepted: true,
557
+ message: "Stage-manager trigger accepted for the requested channel."
558
+ };
559
+ }
560
+ const history = await appendStageManagerHistory(storage, {
561
+ id: randomUUID(),
562
+ tool: "no_change",
563
+ affectedMemoryIds: [],
564
+ summary: "Manual trigger accepted without a target guild/channel.",
565
+ createdAt: nowIso()
566
+ });
567
+ return {
568
+ accepted: true,
569
+ message: "Stage-manager trigger accepted and recorded in history.",
570
+ history
571
+ };
572
+ });
573
+ app.get("/api/diagnostics/bundle", async () => diagnosticBundle(storage, options.runtime));
574
+ app.get("/api/events", async (request, reply) => sendSseSnapshot(request, reply, options.runtime, logger));
575
+ app.setNotFoundHandler(async (request, reply) => {
576
+ if (request.url.startsWith("/api/")) {
577
+ return reply.status(404).send({ error: "NotFound", message: `${request.method} ${request.url} was not found.` });
578
+ }
579
+ if (await tryServeFrontend(request, reply, options.dataRoot)) {
580
+ return reply;
581
+ }
582
+ return reply.status(404).send({ error: "NotFound", message: `${request.method} ${request.url} was not found.` });
583
+ });
584
+ return app;
585
+ }
586
+ async function readDiscordBots(storage) {
587
+ return storage.readJson("user/discord-bots.json", DiscordBotsFileSchema, emptyDiscordBots());
588
+ }
589
+ function emptyDiscordBots() {
590
+ return DiscordBotsFileSchema.parse(createEmptyRevisionedFile({ orchestrator: null, waifus: [] }));
591
+ }
592
+ function mergeDiscordBots(current, patch) {
593
+ const orchestrator = patch.orchestrator === undefined
594
+ ? current.orchestrator
595
+ : patch.orchestrator === null
596
+ ? null
597
+ : {
598
+ ...patch.orchestrator,
599
+ token: patch.orchestrator.token ?? current.orchestrator?.token
600
+ };
601
+ const currentWaifus = new Map(current.waifus.map((bot) => [bot.id, bot]));
602
+ const waifus = patch.waifus
603
+ ? patch.waifus.map((bot) => ({
604
+ ...bot,
605
+ token: bot.token ?? currentWaifus.get(bot.id)?.token
606
+ }))
607
+ : current.waifus;
608
+ return {
609
+ ...current,
610
+ orchestrator,
611
+ waifus
612
+ };
613
+ }
614
+ function sanitizeDiscordBots(file) {
615
+ return {
616
+ ...file,
617
+ orchestrator: file.orchestrator ? sanitizeDiscordBot(file.orchestrator) : null,
618
+ waifus: file.waifus.map(sanitizeDiscordBot)
619
+ };
620
+ }
621
+ function sanitizeDiscordBot(bot) {
622
+ return {
623
+ ...bot,
624
+ token: undefined,
625
+ tokenConfigured: Boolean(bot.token),
626
+ tokenHint: bot.token ? keyHint(bot.token) : undefined
627
+ };
628
+ }
629
+ async function readAgentConfig(storage, agent) {
630
+ const defaultWindow = agent === "stage-manager" ? 80 : 20;
631
+ return storage.readJson(`user/${agent}/config.json`, AgentConfigSchema, AgentConfigSchema.parse(createEmptyRevisionedFile({
632
+ enabled: false,
633
+ contextWindow: defaultWindow,
634
+ prompt: ""
635
+ })));
636
+ }
637
+ async function updateAgentConfig(storage, request, agent, body, defaultContextWindow) {
638
+ return storage.updateRevisionedJson({
639
+ resourceKey: agent,
640
+ relativePath: `user/${agent}/config.json`,
641
+ schema: AgentConfigSchema,
642
+ fallback: AgentConfigSchema.parse(createEmptyRevisionedFile({
643
+ enabled: false,
644
+ contextWindow: defaultContextWindow,
645
+ prompt: ""
646
+ })),
647
+ expectedRevision: expectedRevision(request, body),
648
+ transform: (current) => {
649
+ const { revision: _revision, schemaVersion: _schemaVersion, updatedAt: _updatedAt, ...patch } = body;
650
+ return {
651
+ ...current,
652
+ ...patch
653
+ };
654
+ }
655
+ });
656
+ }
657
+ async function readOrchestratorHistory(storage) {
658
+ return storage.readJson("user/orchestrator/history.json", OrchestratorHistoryFileSchema, emptyOrchestratorHistory());
659
+ }
660
+ function emptyOrchestratorHistory() {
661
+ return OrchestratorHistoryFileSchema.parse(createEmptyRevisionedFile({ decisions: [] }));
662
+ }
663
+ async function appendOrchestratorHistory(storage, entry) {
664
+ return storage.updateRevisionedJson({
665
+ resourceKey: "orchestrator:history",
666
+ relativePath: "user/orchestrator/history.json",
667
+ schema: OrchestratorHistoryFileSchema,
668
+ fallback: emptyOrchestratorHistory(),
669
+ transform: (current) => ({
670
+ ...current,
671
+ decisions: [entry, ...current.decisions].slice(0, 200)
672
+ })
673
+ });
674
+ }
675
+ async function readStageManagerHistory(storage) {
676
+ return storage.readJson("user/stage-manager/history.json", StageManagerHistoryFileSchema, emptyStageManagerHistory());
677
+ }
678
+ function emptyStageManagerHistory() {
679
+ return StageManagerHistoryFileSchema.parse(createEmptyRevisionedFile({ edits: [] }));
680
+ }
681
+ async function appendStageManagerHistory(storage, entry) {
682
+ return storage.updateRevisionedJson({
683
+ resourceKey: "stage-manager:history",
684
+ relativePath: "user/stage-manager/history.json",
685
+ schema: StageManagerHistoryFileSchema,
686
+ fallback: emptyStageManagerHistory(),
687
+ transform: (current) => ({
688
+ ...current,
689
+ edits: [entry, ...current.edits].slice(0, 200)
690
+ })
691
+ });
692
+ }
693
+ async function readReviewerHistory(storage) {
694
+ return storage.readJson("user/reviewer/history.json", ReviewerHistoryFileSchema, emptyReviewerHistory());
695
+ }
696
+ function emptyReviewerHistory() {
697
+ return ReviewerHistoryFileSchema.parse(createEmptyRevisionedFile({ reviews: [] }));
698
+ }
699
+ async function readProviderCredentials(storage) {
700
+ return storage.readJson("user/providers.json", ProviderCredentialsFileSchema, emptyProviderCredentials());
701
+ }
702
+ function emptyProviderCredentials() {
703
+ return ProviderCredentialsFileSchema.parse(createEmptyRevisionedFile({ providers: {} }));
704
+ }
705
+ function providerCredentialsResponse(file, providerId) {
706
+ const saved = file.providers[providerId];
707
+ return {
708
+ revision: file.revision,
709
+ updatedAt: file.updatedAt,
710
+ providerId,
711
+ credentials: saved
712
+ ? {
713
+ configured: true,
714
+ label: saved.label,
715
+ updatedAt: saved.updatedAt,
716
+ keyHint: keyHint(saved.apiKey)
717
+ }
718
+ : { configured: false }
719
+ };
720
+ }
721
+ function keyHint(value) {
722
+ return value.length <= 4 ? "****" : `****${value.slice(-4)}`;
723
+ }
724
+ async function listWaifus(storage) {
725
+ const root = userDataPath(storage.dataRoot, "waifus");
726
+ let entries;
727
+ try {
728
+ entries = await readdir(root);
729
+ }
730
+ catch (error) {
731
+ if (error.code === "ENOENT") {
732
+ return [];
733
+ }
734
+ throw error;
735
+ }
736
+ const waifus = await Promise.all(entries.map(async (entry) => {
737
+ try {
738
+ return await storage.readJson(waifuRelativePath(entry), WaifuConfigSchema);
739
+ }
740
+ catch {
741
+ return undefined;
742
+ }
743
+ }));
744
+ return waifus.filter((waifu) => waifu !== undefined);
745
+ }
746
+ async function readWaifu(storage, waifuId) {
747
+ assertSafeId(waifuId);
748
+ try {
749
+ return await storage.readJson(waifuRelativePath(waifuId), WaifuConfigSchema);
750
+ }
751
+ catch (error) {
752
+ if (error.code === "ENOENT") {
753
+ throw notFound(`Waifu ${waifuId} was not found.`);
754
+ }
755
+ throw error;
756
+ }
757
+ }
758
+ function waifuRelativePath(waifuId) {
759
+ assertSafeId(waifuId);
760
+ return path.join("user", "waifus", waifuId, "waifu.json");
761
+ }
762
+ async function listServers(storage) {
763
+ const root = userDataPath(storage.dataRoot, "servers");
764
+ let entries;
765
+ try {
766
+ entries = await readdir(root);
767
+ }
768
+ catch (error) {
769
+ if (error.code === "ENOENT") {
770
+ return [];
771
+ }
772
+ throw error;
773
+ }
774
+ const servers = await Promise.all(entries.map(async (entry) => {
775
+ try {
776
+ return await storage.readJson(serverRelativePath(entry), ServerConfigSchema);
777
+ }
778
+ catch {
779
+ return undefined;
780
+ }
781
+ }));
782
+ return servers.filter((server) => server !== undefined);
783
+ }
784
+ function serverRelativePath(guildId) {
785
+ assertSafeId(guildId);
786
+ return path.join("user", "servers", guildId, "server.json");
787
+ }
788
+ async function readMembers(storage, guildId) {
789
+ assertSafeId(guildId);
790
+ const [file, bots] = await Promise.all([
791
+ storage.readJson(path.join("user", "servers", guildId, "members.json"), GuildMembersFileSchema, GuildMembersFileSchema.parse(createEmptyRevisionedFile({ guildId, members: [] }))),
792
+ readDiscordBots(storage)
793
+ ]);
794
+ return {
795
+ ...file,
796
+ members: mergeConfiguredBotsIntoMembers(file.members, bots)
797
+ };
798
+ }
799
+ async function readEmojis(storage, guildId) {
800
+ assertSafeId(guildId);
801
+ return storage.readJson(path.join("user", "servers", guildId, "emojis.json"), GuildEmojisFileSchema, GuildEmojisFileSchema.parse(createEmptyRevisionedFile({ guildId, emojis: [] })));
802
+ }
803
+ async function readRoles(storage, guildId) {
804
+ assertSafeId(guildId);
805
+ return storage.readJson(path.join("user", "servers", guildId, "roles.json"), GuildRolesFileSchema, GuildRolesFileSchema.parse(createEmptyRevisionedFile({ guildId, roles: [] })));
806
+ }
807
+ async function readMemoryStore(storage) {
808
+ return storage.readJson("user/memories.json", MemoryStoreSchema, emptyMemoryStore());
809
+ }
810
+ function emptyMemoryStore() {
811
+ return MemoryStoreSchema.parse(createEmptyRevisionedFile({ memories: [] }));
812
+ }
813
+ async function readShortTermMemoryStore(storage) {
814
+ return storage.readJson("user/short-term-memories.json", ShortTermMemoryStoreSchema, emptyShortTermMemoryStore());
815
+ }
816
+ function emptyShortTermMemoryStore() {
817
+ return ShortTermMemoryStoreSchema.parse(createEmptyRevisionedFile({ entries: [] }));
818
+ }
819
+ function updateShortTermMemoryOrThrow(current, entryId, patch) {
820
+ let found = false;
821
+ const entries = current.entries.map((entry) => {
822
+ if (entry.id !== entryId) {
823
+ return entry;
824
+ }
825
+ found = true;
826
+ const { revision: _revision, ...editable } = patch;
827
+ return {
828
+ ...entry,
829
+ ...editable
830
+ };
831
+ });
832
+ if (!found) {
833
+ throw notFound(`Short-term memory ${entryId} was not found.`);
834
+ }
835
+ return {
836
+ ...current,
837
+ entries
838
+ };
839
+ }
840
+ function updateMemoryOrThrow(current, memoryId, patch) {
841
+ let found = false;
842
+ const now = nowIso();
843
+ const memories = current.memories.map((memory) => {
844
+ if (memory.id !== memoryId) {
845
+ return memory;
846
+ }
847
+ found = true;
848
+ const { revision: _revision, scope: _scope, ...editable } = patch;
849
+ return {
850
+ ...memory,
851
+ ...editable,
852
+ scope: "guild",
853
+ updatedAt: now
854
+ };
855
+ });
856
+ if (!found) {
857
+ throw notFound(`Memory ${memoryId} was not found.`);
858
+ }
859
+ return {
860
+ ...current,
861
+ memories
862
+ };
863
+ }
864
+ function parseIdParam(params, key) {
865
+ const parsed = z.record(z.string(), z.string().min(1)).parse(params);
866
+ assertSafeId(parsed[key]);
867
+ return parsed;
868
+ }
869
+ function assertSafeId(id) {
870
+ if (!/^[A-Za-z0-9._:-]+$/.test(id)) {
871
+ throw badRequest(`Invalid id: ${id}`);
872
+ }
873
+ }
874
+ function slugId(name) {
875
+ const slug = name
876
+ .trim()
877
+ .toLowerCase()
878
+ .replace(/[^a-z0-9._:-]+/g, "-")
879
+ .replace(/^-+|-+$/g, "");
880
+ return slug || randomUUID();
881
+ }
882
+ async function saveWaifuAsset(dataRoot, request, reply, waifuId, kind) {
883
+ const contentType = String(request.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
884
+ const ext = ASSET_EXT_BY_MIME[contentType];
885
+ if (!ext) {
886
+ throw badRequest("Asset upload requires image/png, image/jpeg, image/webp, or image/gif.");
887
+ }
888
+ if (!Buffer.isBuffer(request.body)) {
889
+ throw badRequest("Asset upload body must be raw image bytes.");
890
+ }
891
+ const body = request.body;
892
+ if (body.byteLength === 0) {
893
+ throw badRequest("Asset upload body is empty.");
894
+ }
895
+ if (body.byteLength > 8 * 1024 * 1024) {
896
+ throw badRequest("Asset upload is too large. Maximum size is 8 MB.");
897
+ }
898
+ const dir = resolveDataPath(dataRoot, "user", "waifus", waifuId);
899
+ await mkdir(dir, { recursive: true });
900
+ await Promise.all(Object.values(ASSET_EXT_BY_MIME).map((knownExt) => rm(path.join(dir, `${kind}.${knownExt}`), { force: true })));
901
+ const relativePath = path.join("user", "waifus", waifuId, `${kind}.${ext}`);
902
+ await writeFile(resolveDataPath(dataRoot, relativePath), body, { mode: 0o600 });
903
+ return reply.status(201).send({
904
+ ok: true,
905
+ kind,
906
+ waifuId,
907
+ contentType,
908
+ bytes: body.byteLength,
909
+ relativePath
910
+ });
911
+ }
912
+ async function refreshDiscordMembers(storage, guildId) {
913
+ const token = await readDiscordBotToken(storage);
914
+ if (!token) {
915
+ return {
916
+ accepted: false,
917
+ guildId,
918
+ message: "Discord bot token is not configured. Save the orchestrator bot first.",
919
+ members: await readMembers(storage, guildId)
920
+ };
921
+ }
922
+ const rawMembers = await fetchAllDiscordGuildMembers(token, guildId);
923
+ const now = nowIso();
924
+ const bots = await readDiscordBots(storage);
925
+ const members = mergeConfiguredBotsIntoMembers(rawMembers.map((member) => ({
926
+ userId: member.user.id,
927
+ username: member.user.username,
928
+ globalDisplayName: member.user.global_name ?? undefined,
929
+ guildDisplayName: member.nick ?? member.user.global_name ?? member.user.username,
930
+ bot: member.user.bot ?? false,
931
+ lastSeenAt: now,
932
+ perChannelLastSeenAt: {}
933
+ })), bots);
934
+ const file = await storage.updateRevisionedJson({
935
+ resourceKey: `members:${guildId}`,
936
+ relativePath: path.join("user", "servers", guildId, "members.json"),
937
+ schema: GuildMembersFileSchema,
938
+ fallback: GuildMembersFileSchema.parse(createEmptyRevisionedFile({ guildId, members: [] })),
939
+ transform: (current) => ({
940
+ ...current,
941
+ guildId,
942
+ members
943
+ })
944
+ });
945
+ return {
946
+ accepted: true,
947
+ guildId,
948
+ message: `Fetched ${members.length} members from Discord.`,
949
+ members: file
950
+ };
951
+ }
952
+ async function fetchAllDiscordGuildMembers(token, guildId) {
953
+ const MemberSchema = z.object({
954
+ nick: z.string().nullable().optional(),
955
+ user: z.object({
956
+ id: z.string(),
957
+ username: z.string().optional(),
958
+ global_name: z.string().nullable().optional(),
959
+ bot: z.boolean().optional()
960
+ })
961
+ });
962
+ const members = [];
963
+ let after = "0";
964
+ for (;;) {
965
+ const response = await discordApiFetch(token, `/guilds/${guildId}/members?limit=1000&after=${encodeURIComponent(after)}`);
966
+ const page = z.array(MemberSchema).parse(response);
967
+ members.push(...page);
968
+ if (page.length < 1000) {
969
+ return members;
970
+ }
971
+ after = page[page.length - 1]?.user.id ?? after;
972
+ }
973
+ }
974
+ async function refreshDiscordEmojis(storage, guildId) {
975
+ const token = await readDiscordBotToken(storage);
976
+ if (!token) {
977
+ return {
978
+ accepted: false,
979
+ guildId,
980
+ message: "Discord bot token is not configured. Save the orchestrator bot first.",
981
+ emojis: await readEmojis(storage, guildId)
982
+ };
983
+ }
984
+ const response = await discordApiFetch(token, `/guilds/${guildId}/emojis`);
985
+ const rawEmojis = z
986
+ .array(z.object({
987
+ id: z.string(),
988
+ name: z.string().nullable(),
989
+ animated: z.boolean().optional(),
990
+ available: z.boolean().optional(),
991
+ roles: z.array(z.string()).optional()
992
+ }))
993
+ .parse(response);
994
+ const now = nowIso();
995
+ const emojis = rawEmojis
996
+ .filter((emoji) => emoji.name)
997
+ .map((emoji) => ({
998
+ id: emoji.id,
999
+ name: emoji.name ?? "emoji",
1000
+ animated: emoji.animated ?? false,
1001
+ available: emoji.available ?? true,
1002
+ roles: emoji.roles ?? [],
1003
+ fetchedAt: now
1004
+ }));
1005
+ const file = await storage.updateRevisionedJson({
1006
+ resourceKey: `emojis:${guildId}`,
1007
+ relativePath: path.join("user", "servers", guildId, "emojis.json"),
1008
+ schema: GuildEmojisFileSchema,
1009
+ fallback: GuildEmojisFileSchema.parse(createEmptyRevisionedFile({ guildId, emojis: [] })),
1010
+ transform: (current) => ({
1011
+ ...current,
1012
+ guildId,
1013
+ emojis
1014
+ })
1015
+ });
1016
+ return {
1017
+ accepted: true,
1018
+ guildId,
1019
+ message: `Fetched ${emojis.length} server emojis from Discord.`,
1020
+ emojis: file
1021
+ };
1022
+ }
1023
+ async function refreshDiscordRoles(storage, guildId) {
1024
+ const token = await readDiscordBotToken(storage);
1025
+ if (!token) {
1026
+ return {
1027
+ accepted: false,
1028
+ guildId,
1029
+ message: "Discord bot token is not configured. Save the orchestrator bot first.",
1030
+ roles: await readRoles(storage, guildId)
1031
+ };
1032
+ }
1033
+ const rawRoles = await fetchAllDiscordGuildRoles(token, guildId);
1034
+ const roles = mapDiscordRoles(rawRoles);
1035
+ const file = await storage.updateRevisionedJson({
1036
+ resourceKey: `roles:${guildId}`,
1037
+ relativePath: path.join("user", "servers", guildId, "roles.json"),
1038
+ schema: GuildRolesFileSchema,
1039
+ fallback: GuildRolesFileSchema.parse(createEmptyRevisionedFile({ guildId, roles: [] })),
1040
+ transform: (current) => ({
1041
+ ...current,
1042
+ guildId,
1043
+ roles
1044
+ })
1045
+ });
1046
+ return {
1047
+ accepted: true,
1048
+ guildId,
1049
+ message: `Fetched ${roles.length} server roles from Discord.`,
1050
+ roles: file
1051
+ };
1052
+ }
1053
+ async function fetchAllDiscordGuildRoles(token, guildId) {
1054
+ const response = await discordApiFetch(token, `/guilds/${guildId}/roles`);
1055
+ return z
1056
+ .array(z.object({
1057
+ id: z.string(),
1058
+ name: z.string(),
1059
+ color: z.number().int().nonnegative().optional(),
1060
+ hoist: z.boolean().optional(),
1061
+ mentionable: z.boolean().optional(),
1062
+ managed: z.boolean().optional()
1063
+ }))
1064
+ .parse(response);
1065
+ }
1066
+ function mapDiscordRoles(rawRoles) {
1067
+ const now = nowIso();
1068
+ return rawRoles
1069
+ .filter((role) => role.name !== "@everyone")
1070
+ .map((role) => ({
1071
+ id: role.id,
1072
+ name: role.name,
1073
+ color: role.color ?? 0,
1074
+ hoist: role.hoist ?? false,
1075
+ mentionable: role.mentionable ?? false,
1076
+ managed: role.managed ?? false,
1077
+ fetchedAt: now
1078
+ }));
1079
+ }
1080
+ async function readDiscordBotToken(storage) {
1081
+ const bots = await readDiscordBots(storage);
1082
+ return bots.orchestrator?.enabled === false
1083
+ ? bots.waifus.find((bot) => bot.enabled && bot.token)?.token
1084
+ : bots.orchestrator?.token ?? bots.waifus.find((bot) => bot.enabled && bot.token)?.token;
1085
+ }
1086
+ async function discordApiFetch(token, pathAndQuery) {
1087
+ const response = await fetch(`https://discord.com/api/v10${pathAndQuery}`, {
1088
+ headers: {
1089
+ authorization: token.startsWith("Bot ") ? token : `Bot ${token}`,
1090
+ "user-agent": "Discord-Waifus/0.1"
1091
+ }
1092
+ });
1093
+ const text = await response.text();
1094
+ const parsed = text ? JSON.parse(text) : undefined;
1095
+ if (!response.ok) {
1096
+ const body = DiscordApiErrorSchema.safeParse(parsed);
1097
+ const message = body.success ? body.data.message : response.statusText;
1098
+ throw badRequest(`Discord API request failed (${response.status}): ${message ?? "unknown error"}`);
1099
+ }
1100
+ return parsed;
1101
+ }
1102
+ async function diagnosticBundle(storage, runtime) {
1103
+ const [providers, bots, orchestrator, stageManager, memories] = await Promise.all([
1104
+ readProviderCredentials(storage),
1105
+ readDiscordBots(storage),
1106
+ readAgentConfig(storage, "orchestrator"),
1107
+ readAgentConfig(storage, "stage-manager"),
1108
+ readMemoryStore(storage)
1109
+ ]);
1110
+ return {
1111
+ generatedAt: nowIso(),
1112
+ runtime,
1113
+ providers: {
1114
+ revision: providers.revision,
1115
+ configured: Object.fromEntries(Object.entries(providers.providers).map(([id, credentials]) => [
1116
+ id,
1117
+ {
1118
+ label: credentials.label,
1119
+ updatedAt: credentials.updatedAt,
1120
+ keyHint: keyHint(credentials.apiKey)
1121
+ }
1122
+ ]))
1123
+ },
1124
+ discord: {
1125
+ revision: bots.revision,
1126
+ orchestratorConfigured: Boolean(bots.orchestrator?.token),
1127
+ orchestratorApplicationId: bots.orchestrator?.applicationId,
1128
+ waifuBotCount: bots.waifus.length,
1129
+ configuredWaifuBotCount: bots.waifus.filter((bot) => bot.token).length
1130
+ },
1131
+ orchestrator: {
1132
+ revision: orchestrator.revision,
1133
+ providerId: orchestrator.providerId,
1134
+ modelId: orchestrator.modelId,
1135
+ contextWindow: orchestrator.contextWindow,
1136
+ promptLength: orchestrator.prompt.length
1137
+ },
1138
+ stageManager: {
1139
+ revision: stageManager.revision,
1140
+ providerId: stageManager.providerId,
1141
+ modelId: stageManager.modelId,
1142
+ contextWindow: stageManager.contextWindow,
1143
+ promptLength: stageManager.prompt.length
1144
+ },
1145
+ memories: {
1146
+ revision: memories.revision,
1147
+ count: memories.memories.length
1148
+ }
1149
+ };
1150
+ }
1151
+ async function resolveStaticDir(configured) {
1152
+ const candidates = configured
1153
+ ? [path.resolve(configured)]
1154
+ : [
1155
+ // Default to the frontend bundled with the installed package, resolved
1156
+ // relative to this module rather than process.cwd(), so `waifus start`
1157
+ // serves the dashboard no matter which directory it was launched from.
1158
+ // (dist/api/server.js and src/api/server.ts both sit two levels under the
1159
+ // package root, where dist-frontend/ lives.)
1160
+ path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "dist-frontend"),
1161
+ // Backwards-compatible fallback for setups that relied on the working directory.
1162
+ path.resolve(process.cwd(), "dist-frontend")
1163
+ ];
1164
+ for (const candidate of candidates) {
1165
+ if (await exists(candidate)) {
1166
+ return candidate;
1167
+ }
1168
+ }
1169
+ return undefined;
1170
+ }
1171
+ async function tryServeFrontend(request, reply, dataRoot, overrideUrl) {
1172
+ const config = await loadAppConfig(dataRoot);
1173
+ const staticDir = await resolveStaticDir(config.frontend.staticDir);
1174
+ if (!staticDir) {
1175
+ return false;
1176
+ }
1177
+ const url = new URL(overrideUrl ?? request.url, "http://waifus.local");
1178
+ const requestedPath = decodeURIComponent(url.pathname);
1179
+ const relativePath = requestedPath === "/" ? "index.html" : requestedPath.replace(/^\/+/, "");
1180
+ const filePath = path.resolve(staticDir, relativePath);
1181
+ if (!filePath.startsWith(staticDir + path.sep) && filePath !== staticDir) {
1182
+ return false;
1183
+ }
1184
+ const candidate = await readableFile(filePath) ? filePath : path.join(staticDir, "index.html");
1185
+ if (!(await readableFile(candidate))) {
1186
+ return false;
1187
+ }
1188
+ const body = await readFile(candidate);
1189
+ reply.header("content-type", contentTypeFor(candidate));
1190
+ reply.header("cache-control", candidate.endsWith("index.html") ? "no-cache" : "public, max-age=31536000, immutable");
1191
+ reply.send(body);
1192
+ return true;
1193
+ }
1194
+ async function readableFile(filePath) {
1195
+ try {
1196
+ const info = await stat(filePath);
1197
+ return info.isFile();
1198
+ }
1199
+ catch {
1200
+ return false;
1201
+ }
1202
+ }
1203
+ function contentTypeFor(filePath) {
1204
+ switch (path.extname(filePath).toLowerCase()) {
1205
+ case ".html":
1206
+ return "text/html; charset=utf-8";
1207
+ case ".js":
1208
+ return "text/javascript; charset=utf-8";
1209
+ case ".css":
1210
+ return "text/css; charset=utf-8";
1211
+ case ".json":
1212
+ return "application/json; charset=utf-8";
1213
+ case ".svg":
1214
+ return "image/svg+xml";
1215
+ case ".png":
1216
+ return "image/png";
1217
+ case ".jpg":
1218
+ case ".jpeg":
1219
+ return "image/jpeg";
1220
+ case ".webp":
1221
+ return "image/webp";
1222
+ case ".gif":
1223
+ return "image/gif";
1224
+ default:
1225
+ return "application/octet-stream";
1226
+ }
1227
+ }
1228
+ async function exists(filePath) {
1229
+ try {
1230
+ await access(filePath);
1231
+ return true;
1232
+ }
1233
+ catch {
1234
+ return false;
1235
+ }
1236
+ }
1237
+ function expectedRevision(request, body) {
1238
+ if (body?.revision !== undefined) {
1239
+ return body.revision;
1240
+ }
1241
+ const ifMatch = request.headers["if-match"];
1242
+ if (typeof ifMatch !== "string") {
1243
+ return undefined;
1244
+ }
1245
+ const normalized = ifMatch.replace(/^W\//, "").replace(/^"|"$/g, "");
1246
+ const parsed = Number.parseInt(normalized, 10);
1247
+ return Number.isFinite(parsed) ? parsed : undefined;
1248
+ }
1249
+ function requireRevision(request, body) {
1250
+ if (expectedRevision(request, body) === undefined) {
1251
+ throw preconditionRequired("This endpoint requires revision in the body or an If-Match header.");
1252
+ }
1253
+ }
1254
+ function sendSseSnapshot(request, reply, runtime, logger) {
1255
+ reply.raw.writeHead(200, {
1256
+ "content-type": "text/event-stream",
1257
+ "cache-control": "no-cache",
1258
+ connection: "keep-alive"
1259
+ });
1260
+ reply.raw.write(`event: runtime\ndata: ${JSON.stringify(runtime)}\n\n`);
1261
+ for (const entry of logger.recent?.().slice().reverse() ?? []) {
1262
+ reply.raw.write(`event: log\ndata: ${JSON.stringify(entry)}\n\n`);
1263
+ }
1264
+ for (const entry of recentQueries()) {
1265
+ reply.raw.write(`event: query\ndata: ${JSON.stringify(entry)}\n\n`);
1266
+ }
1267
+ for (const entry of recentReplies()) {
1268
+ reply.raw.write(`event: reply\ndata: ${JSON.stringify(entry)}\n\n`);
1269
+ }
1270
+ const unsubscribeLogs = logger.subscribe?.((entry) => {
1271
+ reply.raw.write(`event: log\ndata: ${JSON.stringify(entry)}\n\n`);
1272
+ });
1273
+ const unsubscribeQueries = subscribeQueries((entry) => {
1274
+ reply.raw.write(`event: query\ndata: ${JSON.stringify(entry)}\n\n`);
1275
+ });
1276
+ const unsubscribeReplies = subscribeReplies((entry) => {
1277
+ reply.raw.write(`event: reply\ndata: ${JSON.stringify(entry)}\n\n`);
1278
+ });
1279
+ const heartbeat = setInterval(() => {
1280
+ reply.raw.write(`event: heartbeat\ndata: ${JSON.stringify({ time: new Date().toISOString() })}\n\n`);
1281
+ }, 30_000);
1282
+ request.raw.on("close", () => {
1283
+ clearInterval(heartbeat);
1284
+ unsubscribeLogs?.();
1285
+ unsubscribeQueries();
1286
+ unsubscribeReplies();
1287
+ reply.raw.end();
1288
+ });
1289
+ }
1290
+ //# sourceMappingURL=server.js.map