@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.
- package/LICENSE +21 -0
- package/README.md +230 -0
- package/THIRD_PARTY_NOTICES.md +26 -0
- package/assets/ocr/eng.traineddata +0 -0
- package/bin/waifus.mjs +36 -0
- package/dist/api/errors.d.ts +9 -0
- package/dist/api/errors.js +23 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/api/server.d.ts +19 -0
- package/dist/api/server.js +1290 -0
- package/dist/api/server.js.map +1 -0
- package/dist/backend/logger.d.ts +20 -0
- package/dist/backend/logger.js +65 -0
- package/dist/backend/logger.js.map +1 -0
- package/dist/backend/migrations.d.ts +4 -0
- package/dist/backend/migrations.js +521 -0
- package/dist/backend/migrations.js.map +1 -0
- package/dist/backend/redaction.d.ts +1 -0
- package/dist/backend/redaction.js +22 -0
- package/dist/backend/redaction.js.map +1 -0
- package/dist/backend/runtime.d.ts +30 -0
- package/dist/backend/runtime.js +49 -0
- package/dist/backend/runtime.js.map +1 -0
- package/dist/backend/server.d.ts +18 -0
- package/dist/backend/server.js +581 -0
- package/dist/backend/server.js.map +1 -0
- package/dist/cli/commands.d.ts +35 -0
- package/dist/cli/commands.js +510 -0
- package/dist/cli/commands.js.map +1 -0
- package/dist/cli/main.d.ts +1 -0
- package/dist/cli/main.js +12 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/parser.d.ts +10 -0
- package/dist/cli/parser.js +64 -0
- package/dist/cli/parser.js.map +1 -0
- package/dist/config/appConfig.d.ts +3 -0
- package/dist/config/appConfig.js +27 -0
- package/dist/config/appConfig.js.map +1 -0
- package/dist/config/layout.d.ts +13 -0
- package/dist/config/layout.js +173 -0
- package/dist/config/layout.js.map +1 -0
- package/dist/config/paths.d.ts +5 -0
- package/dist/config/paths.js +20 -0
- package/dist/config/paths.js.map +1 -0
- package/dist/config/prebuiltWaifus.d.ts +3 -0
- package/dist/config/prebuiltWaifus.js +106 -0
- package/dist/config/prebuiltWaifus.js.map +1 -0
- package/dist/discord/client.d.ts +394 -0
- package/dist/discord/client.js +1196 -0
- package/dist/discord/client.js.map +1 -0
- package/dist/discord/contextBuilder.d.ts +15 -0
- package/dist/discord/contextBuilder.js +18 -0
- package/dist/discord/contextBuilder.js.map +1 -0
- package/dist/discord/intents.d.ts +5 -0
- package/dist/discord/intents.js +21 -0
- package/dist/discord/intents.js.map +1 -0
- package/dist/discord/memberCache.d.ts +12 -0
- package/dist/discord/memberCache.js +55 -0
- package/dist/discord/memberCache.js.map +1 -0
- package/dist/discord/normalization.d.ts +34 -0
- package/dist/discord/normalization.js +330 -0
- package/dist/discord/normalization.js.map +1 -0
- package/dist/discord/rateLimit.d.ts +9 -0
- package/dist/discord/rateLimit.js +11 -0
- package/dist/discord/rateLimit.js.map +1 -0
- package/dist/orchestration/context.d.ts +85 -0
- package/dist/orchestration/context.js +200 -0
- package/dist/orchestration/context.js.map +1 -0
- package/dist/orchestration/decisions.d.ts +52 -0
- package/dist/orchestration/decisions.js +61 -0
- package/dist/orchestration/decisions.js.map +1 -0
- package/dist/orchestration/messageSplit.d.ts +6 -0
- package/dist/orchestration/messageSplit.js +118 -0
- package/dist/orchestration/messageSplit.js.map +1 -0
- package/dist/orchestration/ocr.d.ts +43 -0
- package/dist/orchestration/ocr.js +504 -0
- package/dist/orchestration/ocr.js.map +1 -0
- package/dist/orchestration/ocrPackages.d.ts +24 -0
- package/dist/orchestration/ocrPackages.js +85 -0
- package/dist/orchestration/ocrPackages.js.map +1 -0
- package/dist/orchestration/promptBlocks.d.ts +28 -0
- package/dist/orchestration/promptBlocks.js +232 -0
- package/dist/orchestration/promptBlocks.js.map +1 -0
- package/dist/orchestration/replyQuote.d.ts +7 -0
- package/dist/orchestration/replyQuote.js +198 -0
- package/dist/orchestration/replyQuote.js.map +1 -0
- package/dist/orchestration/reviewer.d.ts +5 -0
- package/dist/orchestration/reviewer.js +5 -0
- package/dist/orchestration/reviewer.js.map +1 -0
- package/dist/orchestration/runtime.d.ts +152 -0
- package/dist/orchestration/runtime.js +3535 -0
- package/dist/orchestration/runtime.js.map +1 -0
- package/dist/orchestration/session.d.ts +30 -0
- package/dist/orchestration/session.js +41 -0
- package/dist/orchestration/session.js.map +1 -0
- package/dist/orchestration/stageManager.d.ts +42 -0
- package/dist/orchestration/stageManager.js +50 -0
- package/dist/orchestration/stageManager.js.map +1 -0
- package/dist/providers/catalog.d.ts +6 -0
- package/dist/providers/catalog.js +212 -0
- package/dist/providers/catalog.js.map +1 -0
- package/dist/providers/pipelines.d.ts +13 -0
- package/dist/providers/pipelines.js +2388 -0
- package/dist/providers/pipelines.js.map +1 -0
- package/dist/providers/types.d.ts +91 -0
- package/dist/providers/types.js +2 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/shared/queryLog.d.ts +39 -0
- package/dist/shared/queryLog.js +80 -0
- package/dist/shared/queryLog.js.map +1 -0
- package/dist/shared/schemas/common.d.ts +21 -0
- package/dist/shared/schemas/common.js +27 -0
- package/dist/shared/schemas/common.js.map +1 -0
- package/dist/shared/schemas/config.d.ts +57 -0
- package/dist/shared/schemas/config.js +49 -0
- package/dist/shared/schemas/config.js.map +1 -0
- package/dist/shared/schemas/domain.d.ts +825 -0
- package/dist/shared/schemas/domain.js +444 -0
- package/dist/shared/schemas/domain.js.map +1 -0
- package/dist/storage/atomic.d.ts +6 -0
- package/dist/storage/atomic.js +60 -0
- package/dist/storage/atomic.js.map +1 -0
- package/dist/storage/errors.d.ts +12 -0
- package/dist/storage/errors.js +23 -0
- package/dist/storage/errors.js.map +1 -0
- package/dist/storage/locks.d.ts +9 -0
- package/dist/storage/locks.js +42 -0
- package/dist/storage/locks.js.map +1 -0
- package/dist/storage/storageService.d.ts +25 -0
- package/dist/storage/storageService.js +66 -0
- package/dist/storage/storageService.js.map +1 -0
- package/dist-frontend/assets/index-B2bB3_2r.js +39 -0
- package/dist-frontend/assets/index-B2bB3_2r.js.map +1 -0
- package/dist-frontend/assets/index-Dj1L8Px-.css +1 -0
- package/dist-frontend/index.html +14 -0
- package/docs/api.md +162 -0
- package/docs/old-orchestrator-prompt.md +123 -0
- 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
|