@diegoaltoworks/chatter 0.45.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -5
- package/dist/auth/apikeys.d.ts.map +1 -1
- package/dist/auth/secretStrength.d.ts +8 -0
- package/dist/auth/secretStrength.d.ts.map +1 -0
- package/dist/bin/create-apikey.js +22 -3
- package/dist/bin/wa-pair.js +59 -11
- package/dist/channels/gates.d.ts +9 -0
- package/dist/channels/gates.d.ts.map +1 -1
- package/dist/channels/index.d.ts +1 -0
- package/dist/channels/index.d.ts.map +1 -1
- package/dist/channels/index.js +252 -8
- package/dist/channels/index.mjs +250 -8
- package/dist/channels/pipeline.d.ts +106 -0
- package/dist/channels/pipeline.d.ts.map +1 -0
- package/dist/channels/senders.d.ts +2 -1
- package/dist/channels/senders.d.ts.map +1 -1
- package/dist/channels/whatsapp/channel.d.ts +4 -0
- package/dist/channels/whatsapp/channel.d.ts.map +1 -1
- package/dist/channels/whatsapp/crypto.d.ts +17 -3
- package/dist/channels/whatsapp/crypto.d.ts.map +1 -1
- package/dist/channels/whatsapp/inbound.d.ts +34 -4
- package/dist/channels/whatsapp/inbound.d.ts.map +1 -1
- package/dist/channels/whatsapp/index.d.ts +2 -1
- package/dist/channels/whatsapp/index.d.ts.map +1 -1
- package/dist/channels/whatsapp/index.js +395 -159
- package/dist/channels/whatsapp/index.mjs +394 -160
- package/dist/channels/whatsapp/router.d.ts +107 -0
- package/dist/channels/whatsapp/router.d.ts.map +1 -0
- package/dist/core/answer.d.ts +5 -2
- package/dist/core/answer.d.ts.map +1 -1
- package/dist/core/logger.d.ts +23 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/messages.d.ts +8 -0
- package/dist/core/messages.d.ts.map +1 -1
- package/dist/core/pipeline.d.ts +2 -0
- package/dist/core/pipeline.d.ts.map +1 -1
- package/dist/core/retrieval.d.ts +4 -0
- package/dist/core/retrieval.d.ts.map +1 -1
- package/dist/core/serve-static.d.ts +38 -0
- package/dist/core/serve-static.d.ts.map +1 -0
- package/dist/flows/engine.d.ts +3 -0
- package/dist/flows/engine.d.ts.map +1 -1
- package/dist/flows/index.d.ts +1 -0
- package/dist/flows/index.d.ts.map +1 -1
- package/dist/flows/index.js +91 -25
- package/dist/flows/index.mjs +91 -26
- package/dist/flows/intent.d.ts +2 -1
- package/dist/flows/intent.d.ts.map +1 -1
- package/dist/flows/loader.d.ts +5 -3
- package/dist/flows/loader.d.ts.map +1 -1
- package/dist/flows/types.d.ts +9 -0
- package/dist/flows/types.d.ts.map +1 -1
- package/dist/history/index.d.ts +17 -0
- package/dist/history/index.d.ts.map +1 -0
- package/dist/history/index.js +100 -0
- package/dist/history/index.mjs +73 -0
- package/dist/history/tursoStore.d.ts +26 -0
- package/dist/history/tursoStore.d.ts.map +1 -0
- package/dist/history/types.d.ts +18 -0
- package/dist/history/types.d.ts.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1202 -32462
- package/dist/index.mjs +1118 -32414
- package/dist/mcp-server/cost-tracker.d.ts +11 -6
- package/dist/mcp-server/cost-tracker.d.ts.map +1 -1
- package/dist/mcp-server/logger.d.ts +5 -2
- package/dist/mcp-server/logger.d.ts.map +1 -1
- package/dist/mcp-server/sdk.d.ts +23 -0
- package/dist/mcp-server/sdk.d.ts.map +1 -0
- package/dist/mcp-server/tool-input.d.ts +12 -0
- package/dist/mcp-server/tool-input.d.ts.map +1 -0
- package/dist/mcp-server/types.d.ts +10 -3
- package/dist/mcp-server/types.d.ts.map +1 -1
- package/dist/mcp-server.d.ts +1 -2
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +192 -111
- package/dist/mcp-server.mjs +179 -98
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/bodyLimit.d.ts +11 -0
- package/dist/middleware/bodyLimit.d.ts.map +1 -0
- package/dist/middleware/clientKey.d.ts +15 -0
- package/dist/middleware/clientKey.d.ts.map +1 -0
- package/dist/middleware/cors.d.ts.map +1 -1
- package/dist/middleware/jwt.d.ts +0 -3
- package/dist/middleware/jwt.d.ts.map +1 -1
- package/dist/middleware/originMatch.d.ts +27 -0
- package/dist/middleware/originMatch.d.ts.map +1 -0
- package/dist/middleware/ratelimit.d.ts +0 -6
- package/dist/middleware/ratelimit.d.ts.map +1 -1
- package/dist/middleware/referrer.d.ts +5 -1
- package/dist/middleware/referrer.d.ts.map +1 -1
- package/dist/middleware/securityHeaders.d.ts +20 -0
- package/dist/middleware/securityHeaders.d.ts.map +1 -0
- package/dist/middleware/session.d.ts +2 -1
- package/dist/middleware/session.d.ts.map +1 -1
- package/dist/middleware/windowBucket.d.ts +19 -0
- package/dist/middleware/windowBucket.d.ts.map +1 -0
- package/dist/personas/index.js +20 -1
- package/dist/personas/index.mjs +20 -1
- package/dist/personas/resolver.d.ts +3 -0
- package/dist/personas/resolver.d.ts.map +1 -1
- package/dist/routes/demo.d.ts.map +1 -1
- package/dist/routes/openai.d.ts.map +1 -1
- package/dist/routes/private.d.ts.map +1 -1
- package/dist/routes/public.d.ts.map +1 -1
- package/dist/scheduler/compose.d.ts +2 -1
- package/dist/scheduler/compose.d.ts.map +1 -1
- package/dist/scheduler/index.js +29 -9
- package/dist/scheduler/index.mjs +29 -9
- package/dist/scheduler/scheduler.d.ts +3 -0
- package/dist/scheduler/scheduler.d.ts.map +1 -1
- package/dist/scheduler/tick.d.ts +2 -0
- package/dist/scheduler/tick.d.ts.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +726 -538
- package/dist/server.mjs +708 -527
- package/dist/types.d.ts +87 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +30 -9
package/dist/server.mjs
CHANGED
|
@@ -1,25 +1,31 @@
|
|
|
1
|
-
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
-
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
-
}) : x)(function(x) {
|
|
4
|
-
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
5
|
-
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
|
-
});
|
|
7
|
-
|
|
8
1
|
// src/server.ts
|
|
2
|
+
import { relative } from "node:path";
|
|
9
3
|
import { createClient as createClient2 } from "@libsql/client";
|
|
10
4
|
import { Hono as Hono5 } from "hono";
|
|
11
|
-
import { serveStatic } from "hono/bun";
|
|
12
5
|
import OpenAI from "openai";
|
|
13
6
|
|
|
14
7
|
// src/auth/apikeys.ts
|
|
15
8
|
import { randomUUID } from "node:crypto";
|
|
16
9
|
import { jwtVerify, SignJWT } from "jose";
|
|
10
|
+
|
|
11
|
+
// src/auth/secretStrength.ts
|
|
12
|
+
var MIN_SECRET_LENGTH = 16;
|
|
13
|
+
function assertStrongSecret(secret, label) {
|
|
14
|
+
if (!secret) {
|
|
15
|
+
throw new Error(`${label} is required and cannot be empty.`);
|
|
16
|
+
}
|
|
17
|
+
if (secret.length < MIN_SECRET_LENGTH) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`${label} is too weak: must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). Generate one with \`openssl rand -base64 32\`.`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/auth/apikeys.ts
|
|
17
25
|
var ApiKeyManager = class {
|
|
18
26
|
secretBytes;
|
|
19
27
|
constructor(secret) {
|
|
20
|
-
|
|
21
|
-
throw new Error("API key secret is required");
|
|
22
|
-
}
|
|
28
|
+
assertStrongSecret(secret, "API key secret (CHATTER_SECRET / config.auth.secret)");
|
|
23
29
|
this.secretBytes = new TextEncoder().encode(secret);
|
|
24
30
|
}
|
|
25
31
|
/**
|
|
@@ -122,13 +128,256 @@ var ApiKeyManager = class {
|
|
|
122
128
|
}
|
|
123
129
|
};
|
|
124
130
|
|
|
131
|
+
// src/core/guardrails.ts
|
|
132
|
+
function detectLeakage(text) {
|
|
133
|
+
const flags = [
|
|
134
|
+
/system prompt/i,
|
|
135
|
+
/hidden instruction/i,
|
|
136
|
+
/here (are|is) (my|the) rules/i,
|
|
137
|
+
/BEGIN SYSTEM PROMPT/i,
|
|
138
|
+
/tool instructions/i
|
|
139
|
+
];
|
|
140
|
+
return flags.some((rx) => rx.test(text));
|
|
141
|
+
}
|
|
142
|
+
function scrubOutput(text) {
|
|
143
|
+
return text.replace(/sk-[A-Za-z0-9]{15,}/g, "[REDACTED]").replace(/AIza[0-9A-Za-z_-]{30,}/g, "[REDACTED]");
|
|
144
|
+
}
|
|
145
|
+
function guardOutput(text) {
|
|
146
|
+
if (detectLeakage(text)) {
|
|
147
|
+
return "Sorry, I can't share internal instructions. How else can I help?";
|
|
148
|
+
}
|
|
149
|
+
return scrubOutput(text);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/core/llm.ts
|
|
153
|
+
var DEFAULT_MODEL = "gpt-4o";
|
|
154
|
+
async function completeOnce({
|
|
155
|
+
client,
|
|
156
|
+
system,
|
|
157
|
+
messages,
|
|
158
|
+
temperature = 0.2,
|
|
159
|
+
model = DEFAULT_MODEL
|
|
160
|
+
}) {
|
|
161
|
+
const res = await client.chat.completions.create({
|
|
162
|
+
model,
|
|
163
|
+
temperature,
|
|
164
|
+
messages: [{ role: "system", content: system }, ...messages]
|
|
165
|
+
});
|
|
166
|
+
const text = res.choices[0]?.message?.content ?? "";
|
|
167
|
+
return {
|
|
168
|
+
content: guardOutput(text),
|
|
169
|
+
usage: res.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
async function* completeStream({
|
|
173
|
+
client,
|
|
174
|
+
system,
|
|
175
|
+
messages,
|
|
176
|
+
temperature = 0.2,
|
|
177
|
+
model = DEFAULT_MODEL
|
|
178
|
+
}) {
|
|
179
|
+
const stream5 = await client.chat.completions.create({
|
|
180
|
+
model,
|
|
181
|
+
temperature,
|
|
182
|
+
stream: true,
|
|
183
|
+
messages: [{ role: "system", content: system }, ...messages]
|
|
184
|
+
});
|
|
185
|
+
for await (const chunk2 of stream5) {
|
|
186
|
+
const delta = chunk2.choices[0]?.delta?.content ?? "";
|
|
187
|
+
if (delta) yield scrubOutput(delta);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/core/answer.ts
|
|
192
|
+
var noUsage = () => ({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
|
|
193
|
+
async function runAnswerFn(answerFn, { system, messages, mode, sender, conversationId }) {
|
|
194
|
+
const result = await answerFn({ system, messages, mode, sender, conversationId });
|
|
195
|
+
const raw = typeof result === "string" ? result : result?.content;
|
|
196
|
+
const usage = typeof result === "string" ? void 0 : result?.usage;
|
|
197
|
+
return {
|
|
198
|
+
content: guardOutput(typeof raw === "string" ? raw : ""),
|
|
199
|
+
usage: usage ?? noUsage()
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
async function answerOnce({
|
|
203
|
+
answerFn,
|
|
204
|
+
client,
|
|
205
|
+
system,
|
|
206
|
+
messages,
|
|
207
|
+
mode,
|
|
208
|
+
sender,
|
|
209
|
+
conversationId,
|
|
210
|
+
temperature,
|
|
211
|
+
model
|
|
212
|
+
}) {
|
|
213
|
+
if (answerFn) {
|
|
214
|
+
return runAnswerFn(answerFn, { system, messages, mode, sender, conversationId });
|
|
215
|
+
}
|
|
216
|
+
return completeOnce({ client, system, messages, temperature, model });
|
|
217
|
+
}
|
|
218
|
+
async function* answerStream({
|
|
219
|
+
answerFn,
|
|
220
|
+
client,
|
|
221
|
+
system,
|
|
222
|
+
messages,
|
|
223
|
+
mode,
|
|
224
|
+
sender,
|
|
225
|
+
conversationId,
|
|
226
|
+
temperature,
|
|
227
|
+
model
|
|
228
|
+
}) {
|
|
229
|
+
if (answerFn) {
|
|
230
|
+
const { content } = await runAnswerFn(answerFn, {
|
|
231
|
+
system,
|
|
232
|
+
messages,
|
|
233
|
+
mode,
|
|
234
|
+
sender,
|
|
235
|
+
conversationId
|
|
236
|
+
});
|
|
237
|
+
if (content) yield content;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
yield* completeStream({ client, system, messages, temperature, model });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/core/buckets.ts
|
|
244
|
+
function defaultBuckets(mode) {
|
|
245
|
+
return ["base", mode];
|
|
246
|
+
}
|
|
247
|
+
function isIdentified(sender) {
|
|
248
|
+
return typeof sender === "string" && sender.trim().length > 0;
|
|
249
|
+
}
|
|
250
|
+
function normalize(buckets) {
|
|
251
|
+
const out = [];
|
|
252
|
+
for (const raw of buckets) {
|
|
253
|
+
if (typeof raw !== "string") continue;
|
|
254
|
+
const bucket = raw.trim();
|
|
255
|
+
if (bucket && !out.includes(bucket)) out.push(bucket);
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
async function resolveBuckets({
|
|
260
|
+
mode,
|
|
261
|
+
sender,
|
|
262
|
+
bucketsFor
|
|
263
|
+
}) {
|
|
264
|
+
if (!bucketsFor) return void 0;
|
|
265
|
+
const identified = isIdentified(sender);
|
|
266
|
+
const requested = await bucketsFor(identified ? { mode, sender } : { mode });
|
|
267
|
+
if (!Array.isArray(requested)) return void 0;
|
|
268
|
+
const cleaned = normalize(requested);
|
|
269
|
+
if (identified) return cleaned;
|
|
270
|
+
const ceiling = new Set(defaultBuckets(mode));
|
|
271
|
+
return cleaned.filter((bucket) => ceiling.has(bucket));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// src/core/messages.ts
|
|
275
|
+
function isClientRole(role) {
|
|
276
|
+
return role === "user" || role === "assistant";
|
|
277
|
+
}
|
|
278
|
+
function lastUserMessage(messages) {
|
|
279
|
+
return [...messages].reverse().find((m) => m.role === "user");
|
|
280
|
+
}
|
|
281
|
+
function normalizeMessages(raw) {
|
|
282
|
+
if (!Array.isArray(raw) || raw.length === 0) return null;
|
|
283
|
+
const messages = [];
|
|
284
|
+
for (const entry of raw) {
|
|
285
|
+
if (!entry || typeof entry !== "object") return null;
|
|
286
|
+
const { role, content } = entry;
|
|
287
|
+
if (!isClientRole(role)) continue;
|
|
288
|
+
let text = null;
|
|
289
|
+
if (typeof content === "string") {
|
|
290
|
+
text = content;
|
|
291
|
+
} else if (Array.isArray(content)) {
|
|
292
|
+
text = content.filter(
|
|
293
|
+
(part) => !!part && typeof part === "object" && part.type === "text" && typeof part.text === "string"
|
|
294
|
+
).map((part) => part.text).join("");
|
|
295
|
+
}
|
|
296
|
+
if (text === null) continue;
|
|
297
|
+
messages.push({ role, content: text });
|
|
298
|
+
}
|
|
299
|
+
return messages.length > 0 ? messages : null;
|
|
300
|
+
}
|
|
301
|
+
function normalizeChatBody(body) {
|
|
302
|
+
const source = body && typeof body === "object" ? body : {};
|
|
303
|
+
if (Array.isArray(source.messages)) {
|
|
304
|
+
if (source.messages.length === 0) {
|
|
305
|
+
return { ok: false, error: "messages array cannot be empty" };
|
|
306
|
+
}
|
|
307
|
+
const messages = normalizeMessages(source.messages);
|
|
308
|
+
if (!messages) {
|
|
309
|
+
return {
|
|
310
|
+
ok: false,
|
|
311
|
+
error: "messages must contain user/assistant messages with text content"
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return { ok: true, messages };
|
|
315
|
+
}
|
|
316
|
+
const { message } = source;
|
|
317
|
+
if (message && typeof message !== "object") {
|
|
318
|
+
return { ok: true, messages: [{ role: "user", content: String(message) }] };
|
|
319
|
+
}
|
|
320
|
+
return { ok: false, error: "either 'message' or 'messages' required" };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/core/pipeline.ts
|
|
324
|
+
var MODE_SETTINGS = {
|
|
325
|
+
public: { topK: 6, label: "Context" },
|
|
326
|
+
private: { topK: 8, label: "Internal Context" }
|
|
327
|
+
};
|
|
328
|
+
async function prepareChat({
|
|
329
|
+
store,
|
|
330
|
+
prompts,
|
|
331
|
+
mode,
|
|
332
|
+
messages,
|
|
333
|
+
personaLayer,
|
|
334
|
+
channelHint,
|
|
335
|
+
buckets
|
|
336
|
+
}) {
|
|
337
|
+
const lastUserMsg = lastUserMessage(messages);
|
|
338
|
+
if (!lastUserMsg) {
|
|
339
|
+
throw new Error("no user message found in conversation");
|
|
340
|
+
}
|
|
341
|
+
const { topK, label } = MODE_SETTINGS[mode];
|
|
342
|
+
const ctx = await store.query(lastUserMsg.content, topK, buckets ?? defaultBuckets(mode));
|
|
343
|
+
const persona = personaLayer?.trim() || (mode === "private" ? prompts.privatePersona : prompts.publicPersona);
|
|
344
|
+
const hint = channelHint?.trim();
|
|
345
|
+
const system = [
|
|
346
|
+
prompts.baseSystemRules,
|
|
347
|
+
persona,
|
|
348
|
+
...hint ? [hint] : [],
|
|
349
|
+
`${label}:
|
|
350
|
+
${ctx.join("\n\n")}`
|
|
351
|
+
].join("\n\n");
|
|
352
|
+
return { system, messages, context: ctx };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// src/core/logger.ts
|
|
356
|
+
var LEVEL_ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
357
|
+
function createConsoleLogger(level = "info") {
|
|
358
|
+
const threshold = LEVEL_ORDER[level];
|
|
359
|
+
const write = (at) => (...args) => {
|
|
360
|
+
if (LEVEL_ORDER[at] < threshold) return;
|
|
361
|
+
console.error(`[${at}]`, ...args);
|
|
362
|
+
};
|
|
363
|
+
return {
|
|
364
|
+
debug: write("debug"),
|
|
365
|
+
info: write("info"),
|
|
366
|
+
warn: write("warn"),
|
|
367
|
+
error: write("error")
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function resolveLogger(logger, level) {
|
|
371
|
+
return logger ?? createConsoleLogger(level);
|
|
372
|
+
}
|
|
373
|
+
|
|
125
374
|
// src/channels/senders.ts
|
|
126
|
-
function createSenderRegistry() {
|
|
375
|
+
function createSenderRegistry(logger = createConsoleLogger()) {
|
|
127
376
|
const senders = /* @__PURE__ */ new Map();
|
|
128
377
|
return {
|
|
129
378
|
register(name, sender) {
|
|
130
379
|
if (senders.has(name)) {
|
|
131
|
-
|
|
380
|
+
logger.warn(`Channel sender "${name}" is already registered; overwriting.`);
|
|
132
381
|
}
|
|
133
382
|
senders.set(name, sender);
|
|
134
383
|
},
|
|
@@ -145,7 +394,7 @@ function createSenderRegistry() {
|
|
|
145
394
|
await sender.sendText(chatId, text);
|
|
146
395
|
return true;
|
|
147
396
|
} catch (error) {
|
|
148
|
-
|
|
397
|
+
logger.warn(`Channel sender "${name}" sendText failed:`, error);
|
|
149
398
|
return false;
|
|
150
399
|
}
|
|
151
400
|
},
|
|
@@ -156,7 +405,7 @@ function createSenderRegistry() {
|
|
|
156
405
|
await sender.sendVoice(chatId, payload);
|
|
157
406
|
return true;
|
|
158
407
|
} catch (error) {
|
|
159
|
-
|
|
408
|
+
logger.warn(`Channel sender "${name}" sendVoice failed:`, error);
|
|
160
409
|
return false;
|
|
161
410
|
}
|
|
162
411
|
},
|
|
@@ -167,7 +416,7 @@ function createSenderRegistry() {
|
|
|
167
416
|
await sender.sendMedia(chatId, payload);
|
|
168
417
|
return true;
|
|
169
418
|
} catch (error) {
|
|
170
|
-
|
|
419
|
+
logger.warn(`Channel sender "${name}" sendMedia failed:`, error);
|
|
171
420
|
return false;
|
|
172
421
|
}
|
|
173
422
|
}
|
|
@@ -266,6 +515,7 @@ var VectorStore = class _VectorStore {
|
|
|
266
515
|
authToken: options.databaseAuthToken
|
|
267
516
|
});
|
|
268
517
|
this.knowledgeDir = options.knowledgeDir || "./config/knowledge";
|
|
518
|
+
this.logger = options.logger ?? createConsoleLogger();
|
|
269
519
|
}
|
|
270
520
|
client;
|
|
271
521
|
/**
|
|
@@ -275,9 +525,10 @@ var VectorStore = class _VectorStore {
|
|
|
275
525
|
*/
|
|
276
526
|
db;
|
|
277
527
|
knowledgeDir;
|
|
528
|
+
logger;
|
|
278
529
|
// On boot: ingest new chunks and embed only missing ones.
|
|
279
530
|
async build() {
|
|
280
|
-
|
|
531
|
+
this.logger.info("\u{1F504} Building knowledge base...");
|
|
281
532
|
await this.db.execute(`
|
|
282
533
|
CREATE TABLE IF NOT EXISTS chunks (id TEXT PRIMARY KEY, bucket TEXT NOT NULL, source TEXT NOT NULL, text TEXT NOT NULL);
|
|
283
534
|
`);
|
|
@@ -286,7 +537,7 @@ var VectorStore = class _VectorStore {
|
|
|
286
537
|
`);
|
|
287
538
|
await this.db.execute("CREATE INDEX IF NOT EXISTS idx_chunks_bucket ON chunks(bucket);");
|
|
288
539
|
const docs = loadKnowledge(this.knowledgeDir);
|
|
289
|
-
|
|
540
|
+
this.logger.info(`\u{1F4DA} Loaded ${docs.length} knowledge documents`);
|
|
290
541
|
const rows = [];
|
|
291
542
|
for (const d of docs) {
|
|
292
543
|
for (const part of chunk(d.text)) {
|
|
@@ -294,21 +545,19 @@ var VectorStore = class _VectorStore {
|
|
|
294
545
|
rows.push({ id, bucket: d.bucket, source: d.source, text: part });
|
|
295
546
|
}
|
|
296
547
|
}
|
|
297
|
-
|
|
548
|
+
this.logger.info(`\u{1F4E6} Created ${rows.length} chunks from knowledge documents`);
|
|
298
549
|
await this.cleanupStaleChunks(rows.map((r) => r.id));
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
550
|
+
const UPSERT_BATCH = 500;
|
|
551
|
+
for (let i = 0; i < rows.length; i += UPSERT_BATCH) {
|
|
552
|
+
const batch = rows.slice(i, i + UPSERT_BATCH);
|
|
553
|
+
await this.db.batch(
|
|
554
|
+
batch.map((r) => ({
|
|
303
555
|
sql: `INSERT INTO chunks(id,bucket,source,text) VALUES(?,?,?,?)
|
|
304
556
|
ON CONFLICT(id) DO NOTHING`,
|
|
305
557
|
args: [r.id, r.bucket, r.source, r.text]
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
} catch (e) {
|
|
310
|
-
await tx.rollback();
|
|
311
|
-
throw e;
|
|
558
|
+
})),
|
|
559
|
+
"write"
|
|
560
|
+
);
|
|
312
561
|
}
|
|
313
562
|
const ids = rows.map((r) => r.id);
|
|
314
563
|
const missing = [];
|
|
@@ -323,32 +572,25 @@ var VectorStore = class _VectorStore {
|
|
|
323
572
|
for (const row of res.rows) missing.push(String(row.id));
|
|
324
573
|
}
|
|
325
574
|
if (missing.length === 0) {
|
|
326
|
-
|
|
575
|
+
this.logger.info("\u2705 No new chunks to embed - knowledge base is up to date");
|
|
327
576
|
return;
|
|
328
577
|
}
|
|
329
|
-
|
|
578
|
+
this.logger.info(`\u{1F52E} Embedding ${missing.length} new/updated chunks with ${EMB_MODEL}...`);
|
|
330
579
|
const textById = new Map(rows.map((r) => [r.id, r.text]));
|
|
331
580
|
const BATCH = 96;
|
|
332
581
|
for (let i = 0; i < missing.length; i += BATCH) {
|
|
333
582
|
const batchIds = missing.slice(i, i + BATCH);
|
|
334
583
|
const inputs = batchIds.map((id) => textById.get(id) || "");
|
|
335
584
|
const emb = await this.client.embeddings.create({ model: EMB_MODEL, input: inputs });
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
});
|
|
344
|
-
});
|
|
345
|
-
await tx2.commit();
|
|
346
|
-
} catch (e) {
|
|
347
|
-
await tx2.rollback();
|
|
348
|
-
throw e;
|
|
349
|
-
}
|
|
585
|
+
await this.db.batch(
|
|
586
|
+
emb.data.map((d, idx) => ({
|
|
587
|
+
sql: "INSERT INTO embeddings(id,model,embedding) VALUES(?,?,?)",
|
|
588
|
+
args: [batchIds[idx], EMB_MODEL, JSON.stringify(d.embedding)]
|
|
589
|
+
})),
|
|
590
|
+
"write"
|
|
591
|
+
);
|
|
350
592
|
}
|
|
351
|
-
|
|
593
|
+
this.logger.info(`\u2705 Successfully embedded ${missing.length} new chunks`);
|
|
352
594
|
}
|
|
353
595
|
// Remove chunks from database that no longer exist in markdown files
|
|
354
596
|
async cleanupStaleChunks(currentIds) {
|
|
@@ -359,7 +601,7 @@ var VectorStore = class _VectorStore {
|
|
|
359
601
|
if (staleIds.length === 0) {
|
|
360
602
|
return;
|
|
361
603
|
}
|
|
362
|
-
|
|
604
|
+
this.logger.info(`\u{1F9F9} Cleaning up ${staleIds.length} stale chunks...`);
|
|
363
605
|
const BATCH_SIZE = 500;
|
|
364
606
|
for (let i = 0; i < staleIds.length; i += BATCH_SIZE) {
|
|
365
607
|
const batch = staleIds.slice(i, i + BATCH_SIZE);
|
|
@@ -369,7 +611,7 @@ var VectorStore = class _VectorStore {
|
|
|
369
611
|
args: batch
|
|
370
612
|
});
|
|
371
613
|
}
|
|
372
|
-
|
|
614
|
+
this.logger.info(`\u2713 Cleaned up ${staleIds.length} stale chunks and their embeddings`);
|
|
373
615
|
}
|
|
374
616
|
static cosine(a, b) {
|
|
375
617
|
let dot = 0;
|
|
@@ -399,266 +641,140 @@ var VectorStore = class _VectorStore {
|
|
|
399
641
|
const placeholders = allowed.map(() => "?").join(",");
|
|
400
642
|
const res = await this.db.execute({
|
|
401
643
|
sql: `SELECT c.id, c.text, e.embedding
|
|
402
|
-
FROM chunks c
|
|
403
|
-
JOIN embeddings e ON e.id = c.id
|
|
404
|
-
WHERE c.bucket IN (${placeholders})`,
|
|
405
|
-
args: allowed
|
|
406
|
-
});
|
|
407
|
-
const scored = [];
|
|
408
|
-
for (const row of res.rows) {
|
|
409
|
-
const emb = JSON.parse(String(row.embedding));
|
|
410
|
-
const s = _VectorStore.cosine(qv, emb);
|
|
411
|
-
scored.push({ s, text: String(row.text) });
|
|
412
|
-
}
|
|
413
|
-
scored.sort((a, b) => b.s - a.s);
|
|
414
|
-
return scored.slice(0, k).map((r) => r.text);
|
|
415
|
-
}
|
|
416
|
-
};
|
|
417
|
-
|
|
418
|
-
// src/core/widgets.ts
|
|
419
|
-
import { existsSync } from "node:fs";
|
|
420
|
-
import { dirname, join as join3 } from "node:path";
|
|
421
|
-
import { fileURLToPath } from "node:url";
|
|
422
|
-
function resolveStatic(configStaticDir) {
|
|
423
|
-
if (configStaticDir && existsSync(configStaticDir)) {
|
|
424
|
-
return { staticDir: configStaticDir };
|
|
425
|
-
}
|
|
426
|
-
try {
|
|
427
|
-
if (import.meta.url) {
|
|
428
|
-
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
429
|
-
const staticDir = join3(moduleDir, "static");
|
|
430
|
-
if (existsSync(staticDir)) return { staticDir };
|
|
431
|
-
}
|
|
432
|
-
} catch {
|
|
433
|
-
}
|
|
434
|
-
try {
|
|
435
|
-
if (typeof __dirname !== "undefined") {
|
|
436
|
-
const staticDir = join3(__dirname, "static");
|
|
437
|
-
if (existsSync(staticDir)) return { staticDir };
|
|
438
|
-
}
|
|
439
|
-
} catch {
|
|
440
|
-
}
|
|
441
|
-
return { staticDir: null };
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// src/middleware/cors.ts
|
|
445
|
-
var ALLOWED_HEADERS = "content-type, x-api-key, authorization";
|
|
446
|
-
var ALLOWED_METHODS = "POST, GET, OPTIONS";
|
|
447
|
-
var cors = (allowedOrigins) => async (c, next) => {
|
|
448
|
-
const requestOrigin = c.req.header("origin");
|
|
449
|
-
const allowAll = !allowedOrigins || allowedOrigins.length === 0 || allowedOrigins.includes("*");
|
|
450
|
-
const originHeader = resolveOrigin(requestOrigin, allowedOrigins, allowAll);
|
|
451
|
-
if (c.req.method === "OPTIONS") {
|
|
452
|
-
return new Response(null, {
|
|
453
|
-
status: 204,
|
|
454
|
-
headers: {
|
|
455
|
-
"access-control-allow-origin": originHeader,
|
|
456
|
-
"access-control-allow-headers": ALLOWED_HEADERS,
|
|
457
|
-
"access-control-allow-methods": ALLOWED_METHODS,
|
|
458
|
-
...allowAll ? {} : { vary: "Origin" }
|
|
459
|
-
}
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
c.header("access-control-allow-origin", originHeader);
|
|
463
|
-
c.header("access-control-allow-headers", ALLOWED_HEADERS);
|
|
464
|
-
c.header("access-control-allow-methods", ALLOWED_METHODS);
|
|
465
|
-
if (!allowAll) {
|
|
466
|
-
c.header("vary", "Origin");
|
|
467
|
-
}
|
|
468
|
-
await next();
|
|
469
|
-
};
|
|
470
|
-
function resolveOrigin(requestOrigin, allowedOrigins, allowAll) {
|
|
471
|
-
if (allowAll) return "*";
|
|
472
|
-
if (requestOrigin && allowedOrigins.includes(requestOrigin)) {
|
|
473
|
-
return requestOrigin;
|
|
474
|
-
}
|
|
475
|
-
return allowedOrigins[0];
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
// src/routes/demo.ts
|
|
479
|
-
import { Hono } from "hono";
|
|
480
|
-
import { stream } from "hono/streaming";
|
|
481
|
-
|
|
482
|
-
// src/core/guardrails.ts
|
|
483
|
-
function detectLeakage(text) {
|
|
484
|
-
const flags = [
|
|
485
|
-
/system prompt/i,
|
|
486
|
-
/hidden instruction/i,
|
|
487
|
-
/here (are|is) (my|the) rules/i,
|
|
488
|
-
/BEGIN SYSTEM PROMPT/i,
|
|
489
|
-
/tool instructions/i
|
|
490
|
-
];
|
|
491
|
-
return flags.some((rx) => rx.test(text));
|
|
492
|
-
}
|
|
493
|
-
function scrubOutput(text) {
|
|
494
|
-
return text.replace(/sk-[A-Za-z0-9]{15,}/g, "[REDACTED]").replace(/AIza[0-9A-Za-z_-]{30,}/g, "[REDACTED]");
|
|
495
|
-
}
|
|
496
|
-
function guardOutput(text) {
|
|
497
|
-
if (detectLeakage(text)) {
|
|
498
|
-
return "Sorry, I can't share internal instructions. How else can I help?";
|
|
499
|
-
}
|
|
500
|
-
return scrubOutput(text);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// src/core/llm.ts
|
|
504
|
-
var DEFAULT_MODEL = "gpt-4o";
|
|
505
|
-
async function completeOnce({
|
|
506
|
-
client,
|
|
507
|
-
system,
|
|
508
|
-
messages,
|
|
509
|
-
temperature = 0.2,
|
|
510
|
-
model = DEFAULT_MODEL
|
|
511
|
-
}) {
|
|
512
|
-
const res = await client.chat.completions.create({
|
|
513
|
-
model,
|
|
514
|
-
temperature,
|
|
515
|
-
messages: [{ role: "system", content: system }, ...messages]
|
|
516
|
-
});
|
|
517
|
-
const text = res.choices[0]?.message?.content ?? "";
|
|
518
|
-
return {
|
|
519
|
-
content: guardOutput(text),
|
|
520
|
-
usage: res.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
async function* completeStream({
|
|
524
|
-
client,
|
|
525
|
-
system,
|
|
526
|
-
messages,
|
|
527
|
-
temperature = 0.2,
|
|
528
|
-
model = DEFAULT_MODEL
|
|
529
|
-
}) {
|
|
530
|
-
const stream5 = await client.chat.completions.create({
|
|
531
|
-
model,
|
|
532
|
-
temperature,
|
|
533
|
-
stream: true,
|
|
534
|
-
messages: [{ role: "system", content: system }, ...messages]
|
|
535
|
-
});
|
|
536
|
-
for await (const chunk2 of stream5) {
|
|
537
|
-
const delta = chunk2.choices[0]?.delta?.content ?? "";
|
|
538
|
-
if (delta) yield scrubOutput(delta);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// src/core/answer.ts
|
|
543
|
-
var noUsage = () => ({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
|
|
544
|
-
async function runAnswerFn(answerFn, { system, messages, mode, sender }) {
|
|
545
|
-
const result = await answerFn({ system, messages, mode, sender });
|
|
546
|
-
const raw = typeof result === "string" ? result : result?.content;
|
|
547
|
-
const usage = typeof result === "string" ? void 0 : result?.usage;
|
|
548
|
-
return {
|
|
549
|
-
content: guardOutput(typeof raw === "string" ? raw : ""),
|
|
550
|
-
usage: usage ?? noUsage()
|
|
551
|
-
};
|
|
552
|
-
}
|
|
553
|
-
async function answerOnce({
|
|
554
|
-
answerFn,
|
|
555
|
-
client,
|
|
556
|
-
system,
|
|
557
|
-
messages,
|
|
558
|
-
mode,
|
|
559
|
-
sender,
|
|
560
|
-
temperature,
|
|
561
|
-
model
|
|
562
|
-
}) {
|
|
563
|
-
if (answerFn) {
|
|
564
|
-
return runAnswerFn(answerFn, { system, messages, mode, sender });
|
|
565
|
-
}
|
|
566
|
-
return completeOnce({ client, system, messages, temperature, model });
|
|
567
|
-
}
|
|
568
|
-
async function* answerStream({
|
|
569
|
-
answerFn,
|
|
570
|
-
client,
|
|
571
|
-
system,
|
|
572
|
-
messages,
|
|
573
|
-
mode,
|
|
574
|
-
sender,
|
|
575
|
-
temperature,
|
|
576
|
-
model
|
|
577
|
-
}) {
|
|
578
|
-
if (answerFn) {
|
|
579
|
-
const { content } = await runAnswerFn(answerFn, { system, messages, mode, sender });
|
|
580
|
-
if (content) yield content;
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
yield* completeStream({ client, system, messages, temperature, model });
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// src/core/buckets.ts
|
|
587
|
-
function defaultBuckets(mode) {
|
|
588
|
-
return ["base", mode];
|
|
589
|
-
}
|
|
590
|
-
function isIdentified(sender) {
|
|
591
|
-
return typeof sender === "string" && sender.trim().length > 0;
|
|
592
|
-
}
|
|
593
|
-
function normalize(buckets) {
|
|
594
|
-
const out = [];
|
|
595
|
-
for (const raw of buckets) {
|
|
596
|
-
if (typeof raw !== "string") continue;
|
|
597
|
-
const bucket = raw.trim();
|
|
598
|
-
if (bucket && !out.includes(bucket)) out.push(bucket);
|
|
599
|
-
}
|
|
600
|
-
return out;
|
|
601
|
-
}
|
|
602
|
-
async function resolveBuckets({
|
|
603
|
-
mode,
|
|
604
|
-
sender,
|
|
605
|
-
bucketsFor
|
|
606
|
-
}) {
|
|
607
|
-
if (!bucketsFor) return void 0;
|
|
608
|
-
const identified = isIdentified(sender);
|
|
609
|
-
const requested = await bucketsFor(identified ? { mode, sender } : { mode });
|
|
610
|
-
if (!Array.isArray(requested)) return void 0;
|
|
611
|
-
const cleaned = normalize(requested);
|
|
612
|
-
if (identified) return cleaned;
|
|
613
|
-
const ceiling = new Set(defaultBuckets(mode));
|
|
614
|
-
return cleaned.filter((bucket) => ceiling.has(bucket));
|
|
615
|
-
}
|
|
644
|
+
FROM chunks c
|
|
645
|
+
JOIN embeddings e ON e.id = c.id
|
|
646
|
+
WHERE c.bucket IN (${placeholders})`,
|
|
647
|
+
args: allowed
|
|
648
|
+
});
|
|
649
|
+
const scored = [];
|
|
650
|
+
for (const row of res.rows) {
|
|
651
|
+
const emb = JSON.parse(String(row.embedding));
|
|
652
|
+
const s = _VectorStore.cosine(qv, emb);
|
|
653
|
+
scored.push({ s, text: String(row.text) });
|
|
654
|
+
}
|
|
655
|
+
scored.sort((a, b) => b.s - a.s);
|
|
656
|
+
return scored.slice(0, k).map((r) => r.text);
|
|
657
|
+
}
|
|
658
|
+
};
|
|
616
659
|
|
|
617
|
-
// src/core/
|
|
618
|
-
|
|
619
|
-
|
|
660
|
+
// src/core/serve-static.ts
|
|
661
|
+
var STATIC_ADAPTERS = {
|
|
662
|
+
bun: "hono/bun",
|
|
663
|
+
node: "@hono/node-server/serve-static"
|
|
664
|
+
};
|
|
665
|
+
function detectRuntime() {
|
|
666
|
+
return typeof globalThis.Bun === "undefined" ? "node" : "bun";
|
|
620
667
|
}
|
|
621
|
-
function
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
668
|
+
function wrapMissingAdapterError(runtime, cause) {
|
|
669
|
+
const specifier = STATIC_ADAPTERS[runtime];
|
|
670
|
+
return new Error(
|
|
671
|
+
`Serving static files under ${runtime} requires '${specifier}', which could not be loaded. ` + (runtime === "node" ? "Install the optional peer dependency with `npm install @hono/node-server` (or bun/pnpm/yarn), or run Chatter headless (`features.headless: true`) if it should serve no static assets." : "Upgrade 'hono' to a version that ships the Bun adapter."),
|
|
672
|
+
{ cause }
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
var cache = /* @__PURE__ */ new Map();
|
|
676
|
+
function loadServeStatic(runtime = detectRuntime()) {
|
|
677
|
+
const cached = cache.get(runtime);
|
|
678
|
+
if (cached) return cached;
|
|
679
|
+
const loading = (runtime === "bun" ? import("hono/bun") : import("@hono/node-server/serve-static")).then(
|
|
680
|
+
(module) => module.serveStatic,
|
|
681
|
+
(error) => {
|
|
682
|
+
cache.delete(runtime);
|
|
683
|
+
throw wrapMissingAdapterError(runtime, error);
|
|
635
684
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
return messages.length > 0 ? messages : null;
|
|
685
|
+
);
|
|
686
|
+
cache.set(runtime, loading);
|
|
687
|
+
return loading;
|
|
640
688
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
689
|
+
|
|
690
|
+
// src/core/widgets.ts
|
|
691
|
+
import { existsSync } from "node:fs";
|
|
692
|
+
import { dirname, join as join3 } from "node:path";
|
|
693
|
+
import { fileURLToPath } from "node:url";
|
|
694
|
+
function resolveStatic(configStaticDir) {
|
|
695
|
+
if (configStaticDir && existsSync(configStaticDir)) {
|
|
696
|
+
return { staticDir: configStaticDir };
|
|
697
|
+
}
|
|
698
|
+
try {
|
|
699
|
+
if (import.meta.url) {
|
|
700
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
701
|
+
const staticDir = join3(moduleDir, "static");
|
|
702
|
+
if (existsSync(staticDir)) return { staticDir };
|
|
646
703
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
};
|
|
704
|
+
} catch {
|
|
705
|
+
}
|
|
706
|
+
try {
|
|
707
|
+
if (typeof __dirname !== "undefined") {
|
|
708
|
+
const staticDir = join3(__dirname, "static");
|
|
709
|
+
if (existsSync(staticDir)) return { staticDir };
|
|
653
710
|
}
|
|
654
|
-
|
|
711
|
+
} catch {
|
|
655
712
|
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
713
|
+
return { staticDir: null };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// src/middleware/cors.ts
|
|
717
|
+
var ALLOWED_HEADERS = "content-type, x-api-key, authorization, x-conversation-id";
|
|
718
|
+
var ALLOWED_METHODS = "POST, GET, OPTIONS";
|
|
719
|
+
var EXPOSED_HEADERS = "x-conversation-id";
|
|
720
|
+
var cors = (allowedOrigins) => async (c, next) => {
|
|
721
|
+
const requestOrigin = c.req.header("origin");
|
|
722
|
+
const allowAll = !allowedOrigins || allowedOrigins.length === 0 || allowedOrigins.includes("*");
|
|
723
|
+
const originHeader = resolveOrigin(requestOrigin, allowedOrigins, allowAll);
|
|
724
|
+
if (c.req.method === "OPTIONS") {
|
|
725
|
+
return new Response(null, {
|
|
726
|
+
status: 204,
|
|
727
|
+
headers: {
|
|
728
|
+
"access-control-allow-origin": originHeader,
|
|
729
|
+
"access-control-allow-headers": ALLOWED_HEADERS,
|
|
730
|
+
"access-control-allow-methods": ALLOWED_METHODS,
|
|
731
|
+
"access-control-expose-headers": EXPOSED_HEADERS,
|
|
732
|
+
...allowAll ? {} : { vary: "Origin" }
|
|
733
|
+
}
|
|
734
|
+
});
|
|
659
735
|
}
|
|
660
|
-
|
|
736
|
+
c.header("access-control-allow-origin", originHeader);
|
|
737
|
+
c.header("access-control-allow-headers", ALLOWED_HEADERS);
|
|
738
|
+
c.header("access-control-allow-methods", ALLOWED_METHODS);
|
|
739
|
+
c.header("access-control-expose-headers", EXPOSED_HEADERS);
|
|
740
|
+
if (!allowAll) {
|
|
741
|
+
c.header("vary", "Origin");
|
|
742
|
+
}
|
|
743
|
+
await next();
|
|
744
|
+
};
|
|
745
|
+
function resolveOrigin(requestOrigin, allowedOrigins, allowAll) {
|
|
746
|
+
if (allowAll) return "*";
|
|
747
|
+
if (requestOrigin && allowedOrigins.includes(requestOrigin)) {
|
|
748
|
+
return requestOrigin;
|
|
749
|
+
}
|
|
750
|
+
return allowedOrigins[0];
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// src/middleware/securityHeaders.ts
|
|
754
|
+
var DEFAULT_CSP = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'self'";
|
|
755
|
+
var DEFAULT_HSTS = "max-age=15552000; includeSubDomains";
|
|
756
|
+
function isHttps(c, trustProxy) {
|
|
757
|
+
if (new URL(c.req.url).protocol === "https:") return true;
|
|
758
|
+
if (!trustProxy) return false;
|
|
759
|
+
return c.req.header("x-forwarded-proto")?.toLowerCase() === "https";
|
|
661
760
|
}
|
|
761
|
+
function securityHeaders(config) {
|
|
762
|
+
const csp = config.server?.contentSecurityPolicy || DEFAULT_CSP;
|
|
763
|
+
const hsts = config.server?.strictTransportSecurity ?? DEFAULT_HSTS;
|
|
764
|
+
const trustProxy = config.rateLimit?.trustProxy ?? true;
|
|
765
|
+
return async (c, next) => {
|
|
766
|
+
await next();
|
|
767
|
+
c.header("content-security-policy", csp);
|
|
768
|
+
c.header("x-content-type-options", "nosniff");
|
|
769
|
+
if (hsts && (isHttps(c, trustProxy) || process.env.NODE_ENV === "production")) {
|
|
770
|
+
c.header("strict-transport-security", hsts);
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// src/routes/demo.ts
|
|
776
|
+
import { Hono } from "hono";
|
|
777
|
+
import { stream } from "hono/streaming";
|
|
662
778
|
|
|
663
779
|
// src/core/session.ts
|
|
664
780
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
@@ -711,26 +827,94 @@ function getActiveSessions() {
|
|
|
711
827
|
return sessions.size;
|
|
712
828
|
}
|
|
713
829
|
|
|
830
|
+
// src/middleware/bodyLimit.ts
|
|
831
|
+
import { bodyLimit as honoBodyLimit } from "hono/body-limit";
|
|
832
|
+
var DEFAULT_MAX_REQUEST_BYTES = 262144;
|
|
833
|
+
function chatBodyLimit(maxSize = DEFAULT_MAX_REQUEST_BYTES, toError = (message) => ({ error: message })) {
|
|
834
|
+
return honoBodyLimit({
|
|
835
|
+
maxSize,
|
|
836
|
+
onError: (c) => c.json(toError("Request body too large"), 413)
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// src/middleware/clientKey.ts
|
|
841
|
+
function clientRateLimitKey(c, trustProxy) {
|
|
842
|
+
if (!trustProxy) return "shared";
|
|
843
|
+
const header = c.req.header("x-forwarded-for");
|
|
844
|
+
if (!header) return "unknown-ip";
|
|
845
|
+
return header.split(",")[0].trim() || "unknown-ip";
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// src/middleware/originMatch.ts
|
|
849
|
+
function matchesAllowedOrigin(origin, referer, allowedOrigins) {
|
|
850
|
+
return allowedOrigins.some((allowed) => {
|
|
851
|
+
if (origin === allowed) return true;
|
|
852
|
+
if (referer) {
|
|
853
|
+
if (referer === allowed) return true;
|
|
854
|
+
if (referer.startsWith(`${allowed}/`)) return true;
|
|
855
|
+
if (referer.startsWith(`${allowed}?`)) return true;
|
|
856
|
+
}
|
|
857
|
+
return false;
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
function isLocalhost(value) {
|
|
861
|
+
if (!value) return false;
|
|
862
|
+
try {
|
|
863
|
+
const url = new URL(value);
|
|
864
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
865
|
+
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
866
|
+
} catch {
|
|
867
|
+
return false;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
function matchesAllowedOriginOrLocalhost(origin, referer, allowedOrigins) {
|
|
871
|
+
if (isLocalhost(origin) || isLocalhost(referer)) return true;
|
|
872
|
+
return matchesAllowedOrigin(origin, referer, allowedOrigins);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/middleware/windowBucket.ts
|
|
876
|
+
function createWindowBucketLimiter(windowMs, now = Date.now) {
|
|
877
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
878
|
+
let lastSweep = now();
|
|
879
|
+
function sweep(current) {
|
|
880
|
+
if (current - lastSweep < windowMs) return;
|
|
881
|
+
lastSweep = current;
|
|
882
|
+
for (const [key, bucket] of buckets) {
|
|
883
|
+
if (current - bucket.windowStart >= windowMs) buckets.delete(key);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return {
|
|
887
|
+
/** Records one hit for `key` and reports whether it's still within `limit`. */
|
|
888
|
+
hit(key, limit) {
|
|
889
|
+
const current = now();
|
|
890
|
+
sweep(current);
|
|
891
|
+
const bucket = buckets.get(key);
|
|
892
|
+
if (!bucket || current - bucket.windowStart >= windowMs) {
|
|
893
|
+
buckets.set(key, { count: 1, windowStart: current });
|
|
894
|
+
return 1 <= limit;
|
|
895
|
+
}
|
|
896
|
+
bucket.count += 1;
|
|
897
|
+
return bucket.count <= limit;
|
|
898
|
+
},
|
|
899
|
+
/** Current map size — for tests asserting eviction actually happened. */
|
|
900
|
+
size() {
|
|
901
|
+
return buckets.size;
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
714
906
|
// src/routes/demo.ts
|
|
715
|
-
function checkDemoOrigin(allowedOrigins) {
|
|
907
|
+
function checkDemoOrigin(allowedOrigins, allowLocalhost) {
|
|
716
908
|
const allowAll = allowedOrigins.length === 0 || allowedOrigins.includes("*");
|
|
909
|
+
const matches = allowLocalhost ? matchesAllowedOriginOrLocalhost : matchesAllowedOrigin;
|
|
717
910
|
return async (c, next) => {
|
|
718
|
-
const origin = c.req.header("origin");
|
|
719
|
-
const referer = c.req.header("referer");
|
|
720
|
-
const host = c.req.header("host") || "";
|
|
721
|
-
if (host.startsWith("localhost:") || host.startsWith("127.0.0.1:")) {
|
|
722
|
-
return next();
|
|
723
|
-
}
|
|
724
911
|
if (allowAll) {
|
|
725
912
|
return next();
|
|
726
913
|
}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
if (referer) {
|
|
732
|
-
const allowed = allowedOrigins.some((allowed2) => referer.startsWith(allowed2));
|
|
733
|
-
if (allowed) return next();
|
|
914
|
+
const origin = c.req.header("origin");
|
|
915
|
+
const referer = c.req.header("referer");
|
|
916
|
+
if (matches(origin, referer, allowedOrigins)) {
|
|
917
|
+
return next();
|
|
734
918
|
}
|
|
735
919
|
return c.json(
|
|
736
920
|
{
|
|
@@ -740,22 +924,12 @@ function checkDemoOrigin(allowedOrigins) {
|
|
|
740
924
|
);
|
|
741
925
|
};
|
|
742
926
|
}
|
|
743
|
-
function limitSessionCreation() {
|
|
744
|
-
const
|
|
745
|
-
const WINDOW_MS = 36e5;
|
|
927
|
+
function limitSessionCreation(trustProxy) {
|
|
928
|
+
const limiter = createWindowBucketLimiter(36e5);
|
|
746
929
|
const LIMIT = 10;
|
|
747
930
|
return async (c, next) => {
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
const now = Date.now();
|
|
751
|
-
const b = buckets.get(key) ?? { count: 0, windowStart: now };
|
|
752
|
-
if (now - b.windowStart >= WINDOW_MS) {
|
|
753
|
-
b.count = 0;
|
|
754
|
-
b.windowStart = now;
|
|
755
|
-
}
|
|
756
|
-
b.count += 1;
|
|
757
|
-
buckets.set(key, b);
|
|
758
|
-
if (b.count > LIMIT) {
|
|
931
|
+
const key = `session:${clientRateLimitKey(c, trustProxy)}`;
|
|
932
|
+
if (!limiter.hit(key, LIMIT)) {
|
|
759
933
|
return c.json(
|
|
760
934
|
{
|
|
761
935
|
error: "Session key limit exceeded (10 per hour). Please wait or use your own API key."
|
|
@@ -766,22 +940,12 @@ function limitSessionCreation() {
|
|
|
766
940
|
await next();
|
|
767
941
|
};
|
|
768
942
|
}
|
|
769
|
-
function limitDemo() {
|
|
770
|
-
const
|
|
771
|
-
const WINDOW_MS = 6e4;
|
|
943
|
+
function limitDemo(trustProxy) {
|
|
944
|
+
const limiter = createWindowBucketLimiter(6e4);
|
|
772
945
|
const LIMIT = 10;
|
|
773
946
|
return async (c, next) => {
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
const now = Date.now();
|
|
777
|
-
const b = buckets.get(key) ?? { count: 0, windowStart: now };
|
|
778
|
-
if (now - b.windowStart >= WINDOW_MS) {
|
|
779
|
-
b.count = 0;
|
|
780
|
-
b.windowStart = now;
|
|
781
|
-
}
|
|
782
|
-
b.count += 1;
|
|
783
|
-
buckets.set(key, b);
|
|
784
|
-
if (b.count > LIMIT) {
|
|
947
|
+
const key = `demo:${clientRateLimitKey(c, trustProxy)}`;
|
|
948
|
+
if (!limiter.hit(key, LIMIT)) {
|
|
785
949
|
return c.json(
|
|
786
950
|
{
|
|
787
951
|
error: "Demo rate limit exceeded (10 requests/minute). Please wait or use your own API key."
|
|
@@ -793,82 +957,91 @@ function limitDemo() {
|
|
|
793
957
|
};
|
|
794
958
|
}
|
|
795
959
|
function demoRoutes(deps) {
|
|
796
|
-
const { client, store, config, prompts } = deps;
|
|
960
|
+
const { client, store, config, prompts, logger } = deps;
|
|
797
961
|
const app = new Hono();
|
|
798
962
|
const demoOrigins = config.server?.allowedOrigins ?? [];
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
963
|
+
const allowLocalhostDemo = config.server?.allowLocalhostDemo ?? false;
|
|
964
|
+
const trustProxy = config.rateLimit?.trustProxy ?? true;
|
|
965
|
+
app.get(
|
|
966
|
+
"/api/demo/session",
|
|
967
|
+
checkDemoOrigin(demoOrigins, allowLocalhostDemo),
|
|
968
|
+
limitSessionCreation(trustProxy),
|
|
969
|
+
(c) => {
|
|
970
|
+
const session = createSession({
|
|
971
|
+
maxRequests: 20,
|
|
972
|
+
ttl: 3600
|
|
973
|
+
// 1 hour
|
|
974
|
+
});
|
|
975
|
+
logger.debug(
|
|
976
|
+
`[Demo] Created session: ${session.key.slice(0, 20)}... (${session.maxRequests} requests, ${3600}s TTL)`
|
|
977
|
+
);
|
|
978
|
+
return c.json({
|
|
979
|
+
key: session.key,
|
|
980
|
+
expiresIn: 3600,
|
|
981
|
+
maxRequests: session.maxRequests,
|
|
982
|
+
message: "Use this temporary key in the x-api-key header"
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
);
|
|
815
986
|
app.get("/api/demo/stats", (c) => {
|
|
816
987
|
return c.json({
|
|
817
988
|
activeSessions: getActiveSessions()
|
|
818
989
|
});
|
|
819
990
|
});
|
|
820
|
-
app.post(
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
991
|
+
app.post(
|
|
992
|
+
"/api/demo/chat",
|
|
993
|
+
chatBodyLimit(config.server?.maxRequestBytes),
|
|
994
|
+
// Reject oversized bodies first
|
|
995
|
+
limitDemo(trustProxy),
|
|
996
|
+
async (c) => {
|
|
997
|
+
try {
|
|
998
|
+
const body = await c.req.json().catch(() => ({}));
|
|
999
|
+
const normalized = normalizeChatBody(body);
|
|
1000
|
+
if (!normalized.ok) {
|
|
1001
|
+
return c.json({ error: normalized.error }, 400);
|
|
1002
|
+
}
|
|
1003
|
+
const { messages } = normalized;
|
|
1004
|
+
if (!lastUserMessage(messages)) {
|
|
1005
|
+
return c.json({ error: "no user message found in conversation" }, 400);
|
|
1006
|
+
}
|
|
1007
|
+
const buckets = await resolveBuckets({ mode: "public", bucketsFor: config.bucketsFor });
|
|
1008
|
+
const { system } = await prepareChat({ store, prompts, mode: "public", messages, buckets });
|
|
1009
|
+
const model = config.openai.model || DEFAULT_MODEL;
|
|
1010
|
+
const url = new URL(c.req.url);
|
|
1011
|
+
const wantsStream3 = url.searchParams.get("stream") === "1";
|
|
1012
|
+
if (wantsStream3) {
|
|
1013
|
+
return stream(c, async (s) => {
|
|
1014
|
+
for await (const delta of answerStream({
|
|
1015
|
+
answerFn: config.answerFn,
|
|
1016
|
+
client,
|
|
1017
|
+
system,
|
|
1018
|
+
messages,
|
|
1019
|
+
mode: "public",
|
|
1020
|
+
model
|
|
1021
|
+
})) {
|
|
1022
|
+
await s.write(`data: ${JSON.stringify({ delta })}
|
|
852
1023
|
|
|
853
1024
|
`);
|
|
854
|
-
|
|
855
|
-
|
|
1025
|
+
}
|
|
1026
|
+
await s.write("event: end\ndata: {}\n\n");
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
const out = await answerOnce({
|
|
1030
|
+
answerFn: config.answerFn,
|
|
1031
|
+
client,
|
|
1032
|
+
system,
|
|
1033
|
+
messages,
|
|
1034
|
+
mode: "public",
|
|
1035
|
+
model
|
|
856
1036
|
});
|
|
1037
|
+
return c.json({ reply: out.content });
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
const err = error;
|
|
1040
|
+
logger.error("Demo chat error:", err);
|
|
1041
|
+
return c.json({ error: err.message || "Internal error" }, 500);
|
|
857
1042
|
}
|
|
858
|
-
const out = await answerOnce({
|
|
859
|
-
answerFn: config.answerFn,
|
|
860
|
-
client,
|
|
861
|
-
system,
|
|
862
|
-
messages,
|
|
863
|
-
mode: "public"
|
|
864
|
-
});
|
|
865
|
-
return c.json({ reply: out.content });
|
|
866
|
-
} catch (error) {
|
|
867
|
-
const err = error;
|
|
868
|
-
console.error("Demo chat error:", err);
|
|
869
|
-
return c.json({ error: err.message || "Internal error" }, 500);
|
|
870
1043
|
}
|
|
871
|
-
|
|
1044
|
+
);
|
|
872
1045
|
return app;
|
|
873
1046
|
}
|
|
874
1047
|
|
|
@@ -876,38 +1049,6 @@ ${ctx.join("\n\n")}`
|
|
|
876
1049
|
import { Hono as Hono2 } from "hono";
|
|
877
1050
|
import { stream as stream2 } from "hono/streaming";
|
|
878
1051
|
|
|
879
|
-
// src/core/pipeline.ts
|
|
880
|
-
var MODE_SETTINGS = {
|
|
881
|
-
public: { topK: 6, label: "Context" },
|
|
882
|
-
private: { topK: 8, label: "Internal Context" }
|
|
883
|
-
};
|
|
884
|
-
async function prepareChat({
|
|
885
|
-
store,
|
|
886
|
-
prompts,
|
|
887
|
-
mode,
|
|
888
|
-
messages,
|
|
889
|
-
personaLayer,
|
|
890
|
-
channelHint,
|
|
891
|
-
buckets
|
|
892
|
-
}) {
|
|
893
|
-
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
|
894
|
-
if (!lastUserMsg) {
|
|
895
|
-
throw new Error("no user message found in conversation");
|
|
896
|
-
}
|
|
897
|
-
const { topK, label } = MODE_SETTINGS[mode];
|
|
898
|
-
const ctx = await store.query(lastUserMsg.content, topK, buckets ?? defaultBuckets(mode));
|
|
899
|
-
const persona = personaLayer?.trim() || (mode === "private" ? prompts.privatePersona : prompts.publicPersona);
|
|
900
|
-
const hint = channelHint?.trim();
|
|
901
|
-
const system = [
|
|
902
|
-
prompts.baseSystemRules,
|
|
903
|
-
persona,
|
|
904
|
-
...hint ? [hint] : [],
|
|
905
|
-
`${label}:
|
|
906
|
-
${ctx.join("\n\n")}`
|
|
907
|
-
].join("\n\n");
|
|
908
|
-
return { system, messages };
|
|
909
|
-
}
|
|
910
|
-
|
|
911
1052
|
// src/middleware/jwt.ts
|
|
912
1053
|
import { createRemoteJWKSet, importSPKI, jwtVerify as jwtVerify2 } from "jose";
|
|
913
1054
|
function jwtSubject(c) {
|
|
@@ -934,7 +1075,7 @@ function createJWTMiddleware(config) {
|
|
|
934
1075
|
}
|
|
935
1076
|
throw new Error("JWT configuration missing (JWKS URL or PUBLIC KEY PEM).");
|
|
936
1077
|
}
|
|
937
|
-
return async function
|
|
1078
|
+
return async function requirePrivateJWT(c, next) {
|
|
938
1079
|
const hdr = c.req.header("authorization") ?? "";
|
|
939
1080
|
if (!hdr.startsWith("Bearer ")) return c.json({ error: "Unauthorized" }, 401);
|
|
940
1081
|
const token = hdr.slice("Bearer ".length);
|
|
@@ -947,41 +1088,24 @@ function createJWTMiddleware(config) {
|
|
|
947
1088
|
}
|
|
948
1089
|
};
|
|
949
1090
|
}
|
|
950
|
-
var requirePrivateJWT = createJWTMiddleware({});
|
|
951
1091
|
|
|
952
1092
|
// src/middleware/ratelimit.ts
|
|
953
|
-
var DEMO_KEYS = ["chatter-api-key-here"];
|
|
954
|
-
function getKeyFromIP(c) {
|
|
955
|
-
const h = c.req.header("x-forwarded-for");
|
|
956
|
-
if (h) return h.split(",")[0].trim();
|
|
957
|
-
return "unknown-ip";
|
|
958
|
-
}
|
|
959
1093
|
function createRateLimiter(config) {
|
|
960
|
-
const
|
|
961
|
-
const WINDOW_MS = 6e4;
|
|
1094
|
+
const limiter = createWindowBucketLimiter(6e4);
|
|
962
1095
|
const publicLimit = config.rateLimit?.public || 60;
|
|
963
1096
|
const privateLimit = config.rateLimit?.private || 120;
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
const b = buckets.get(key) ?? { count: 0, windowStart: now };
|
|
967
|
-
if (now - b.windowStart >= WINDOW_MS) {
|
|
968
|
-
b.count = 0;
|
|
969
|
-
b.windowStart = now;
|
|
970
|
-
}
|
|
971
|
-
b.count += 1;
|
|
972
|
-
buckets.set(key, b);
|
|
973
|
-
return b.count <= limit;
|
|
974
|
-
}
|
|
1097
|
+
const trustProxy = config.rateLimit?.trustProxy ?? true;
|
|
1098
|
+
const demoApiKeys = config.rateLimit?.demoApiKeys ?? [];
|
|
975
1099
|
return {
|
|
976
1100
|
// Public: key is IP only
|
|
977
1101
|
// Demo keys get 10x stricter rate limits
|
|
978
1102
|
limitPublic() {
|
|
979
1103
|
return async (c, next) => {
|
|
980
1104
|
const apiKey = c.req.header("x-api-key");
|
|
981
|
-
const isDemoKey = apiKey ?
|
|
982
|
-
const key = `pub:${
|
|
1105
|
+
const isDemoKey = apiKey ? demoApiKeys.includes(apiKey) : false;
|
|
1106
|
+
const key = `pub:${clientRateLimitKey(c, trustProxy)}`;
|
|
983
1107
|
const limit = isDemoKey ? Math.floor(publicLimit / 10) : publicLimit;
|
|
984
|
-
if (!
|
|
1108
|
+
if (!limiter.hit(key, limit)) {
|
|
985
1109
|
return c.json(
|
|
986
1110
|
{
|
|
987
1111
|
error: isDemoKey ? "Demo rate limit exceeded. Please use your own API key for higher limits." : "Rate limit exceeded"
|
|
@@ -997,28 +1121,47 @@ function createRateLimiter(config) {
|
|
|
997
1121
|
return async (c, next) => {
|
|
998
1122
|
const sub = c.jwtSub;
|
|
999
1123
|
const key = `private:${sub ?? "no-sub"}`;
|
|
1000
|
-
if (!
|
|
1124
|
+
if (!limiter.hit(key, privateLimit)) return c.json({ error: "Rate limit" }, 429);
|
|
1001
1125
|
await next();
|
|
1002
1126
|
};
|
|
1003
1127
|
}
|
|
1004
1128
|
};
|
|
1005
1129
|
}
|
|
1006
|
-
var defaultLimiter = createRateLimiter({});
|
|
1007
|
-
var limitPublic = defaultLimiter.limitPublic;
|
|
1008
|
-
var limitPrivate = defaultLimiter.limitPrivate;
|
|
1009
1130
|
|
|
1010
1131
|
// src/routes/openai.ts
|
|
1132
|
+
function apiKeySubject(c) {
|
|
1133
|
+
const sub = c.apiKeySub;
|
|
1134
|
+
return sub && sub.trim().length > 0 ? sub : void 0;
|
|
1135
|
+
}
|
|
1136
|
+
function payloadSub(payload) {
|
|
1137
|
+
const sub = payload?.sub;
|
|
1138
|
+
return typeof sub === "string" ? sub : void 0;
|
|
1139
|
+
}
|
|
1140
|
+
var CONVERSATION_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,200}$/;
|
|
1141
|
+
function sanitizeConversationId(value) {
|
|
1142
|
+
const trimmed = value?.trim();
|
|
1143
|
+
return trimmed && CONVERSATION_ID_PATTERN.test(trimmed) ? trimmed : void 0;
|
|
1144
|
+
}
|
|
1145
|
+
function clientConversationId(c, body) {
|
|
1146
|
+
const header = sanitizeConversationId(c.req.header("x-conversation-id"));
|
|
1147
|
+
if (header) return header;
|
|
1148
|
+
const fromBody = body?.conversation_id;
|
|
1149
|
+
return sanitizeConversationId(typeof fromBody === "string" ? fromBody : void 0);
|
|
1150
|
+
}
|
|
1011
1151
|
function errorJson(c, status, message) {
|
|
1012
1152
|
return c.json(
|
|
1013
1153
|
{ error: { message, type: status === 401 ? "authentication_error" : "invalid_request_error" } },
|
|
1014
1154
|
status
|
|
1015
1155
|
);
|
|
1016
1156
|
}
|
|
1157
|
+
function oversizedBodyError(message) {
|
|
1158
|
+
return { error: { message, type: "invalid_request_error" } };
|
|
1159
|
+
}
|
|
1017
1160
|
function openaiRoutes(deps) {
|
|
1018
1161
|
const { client, store, config, prompts, apiKeyManager } = deps;
|
|
1019
1162
|
const app = new Hono2();
|
|
1020
|
-
const { limitPublic
|
|
1021
|
-
const
|
|
1163
|
+
const { limitPublic, limitPrivate } = createRateLimiter(config);
|
|
1164
|
+
const requirePrivateJWT = createJWTMiddleware(config);
|
|
1022
1165
|
const enablePublic = config.features?.enablePublicChat !== false;
|
|
1023
1166
|
const enablePrivate = config.features?.enablePrivateChat !== false;
|
|
1024
1167
|
const serverModel = config.openai.model || DEFAULT_MODEL;
|
|
@@ -1033,7 +1176,10 @@ function openaiRoutes(deps) {
|
|
|
1033
1176
|
}
|
|
1034
1177
|
try {
|
|
1035
1178
|
const result = await apiKeyManager.verify(key);
|
|
1036
|
-
if (result.valid)
|
|
1179
|
+
if (result.valid) {
|
|
1180
|
+
c.apiKeySub = payloadSub(result.payload);
|
|
1181
|
+
return await next();
|
|
1182
|
+
}
|
|
1037
1183
|
} catch {
|
|
1038
1184
|
}
|
|
1039
1185
|
return errorJson(c, 401, "Invalid API key");
|
|
@@ -1060,6 +1206,9 @@ function openaiRoutes(deps) {
|
|
|
1060
1206
|
} catch {
|
|
1061
1207
|
return errorJson(c, 400, "Conversation must contain at least one user message");
|
|
1062
1208
|
}
|
|
1209
|
+
const sender = jwtSubject(c) ?? apiKeySubject(c);
|
|
1210
|
+
const conversationId = clientConversationId(c, body) ?? crypto.randomUUID();
|
|
1211
|
+
c.header("x-conversation-id", conversationId);
|
|
1063
1212
|
const id = `chatcmpl-${crypto.randomUUID()}`;
|
|
1064
1213
|
const created = Math.floor(Date.now() / 1e3);
|
|
1065
1214
|
if (wantsStream3 === true) {
|
|
@@ -1082,6 +1231,8 @@ function openaiRoutes(deps) {
|
|
|
1082
1231
|
system,
|
|
1083
1232
|
messages,
|
|
1084
1233
|
mode,
|
|
1234
|
+
sender,
|
|
1235
|
+
conversationId,
|
|
1085
1236
|
temperature,
|
|
1086
1237
|
model: serverModel
|
|
1087
1238
|
})) {
|
|
@@ -1097,6 +1248,8 @@ function openaiRoutes(deps) {
|
|
|
1097
1248
|
system,
|
|
1098
1249
|
messages,
|
|
1099
1250
|
mode,
|
|
1251
|
+
sender,
|
|
1252
|
+
conversationId,
|
|
1100
1253
|
temperature,
|
|
1101
1254
|
model: serverModel
|
|
1102
1255
|
});
|
|
@@ -1112,13 +1265,15 @@ function openaiRoutes(deps) {
|
|
|
1112
1265
|
});
|
|
1113
1266
|
};
|
|
1114
1267
|
if (enablePublic) {
|
|
1268
|
+
app.use("/v1/*", chatBodyLimit(config.server?.maxRequestBytes, oversizedBodyError));
|
|
1115
1269
|
app.use("/v1/*", requireApiKey);
|
|
1116
|
-
app.use("/v1/*",
|
|
1270
|
+
app.use("/v1/*", limitPublic());
|
|
1117
1271
|
app.post("/v1/chat/completions", handleChatCompletions("public"));
|
|
1118
1272
|
}
|
|
1119
1273
|
if (enablePrivate) {
|
|
1120
|
-
app.use("/api/private/v1/*",
|
|
1121
|
-
app.use("/api/private/v1/*",
|
|
1274
|
+
app.use("/api/private/v1/*", chatBodyLimit(config.server?.maxRequestBytes, oversizedBodyError));
|
|
1275
|
+
app.use("/api/private/v1/*", requirePrivateJWT);
|
|
1276
|
+
app.use("/api/private/v1/*", limitPrivate());
|
|
1122
1277
|
app.post("/api/private/v1/chat/completions", handleChatCompletions("private"));
|
|
1123
1278
|
}
|
|
1124
1279
|
return app;
|
|
@@ -1136,10 +1291,11 @@ function wantsStream(c) {
|
|
|
1136
1291
|
function privateRoutes(deps) {
|
|
1137
1292
|
const { client, store, config, prompts } = deps;
|
|
1138
1293
|
const app = new Hono3();
|
|
1139
|
-
const
|
|
1140
|
-
const { limitPrivate
|
|
1141
|
-
app.use("/api/private/*",
|
|
1142
|
-
app.use("/api/private/*",
|
|
1294
|
+
const requirePrivateJWT = createJWTMiddleware(config);
|
|
1295
|
+
const { limitPrivate } = createRateLimiter(config);
|
|
1296
|
+
app.use("/api/private/*", chatBodyLimit(config.server?.maxRequestBytes));
|
|
1297
|
+
app.use("/api/private/*", requirePrivateJWT);
|
|
1298
|
+
app.use("/api/private/*", limitPrivate());
|
|
1143
1299
|
app.post("/api/private/chat", async (c) => {
|
|
1144
1300
|
const body = await c.req.json().catch(() => ({}));
|
|
1145
1301
|
const normalized = normalizeChatBody(body);
|
|
@@ -1147,9 +1303,10 @@ function privateRoutes(deps) {
|
|
|
1147
1303
|
return c.json({ error: normalized.error }, 400);
|
|
1148
1304
|
}
|
|
1149
1305
|
const { messages } = normalized;
|
|
1306
|
+
const sender = jwtSubject(c);
|
|
1150
1307
|
const buckets = await resolveBuckets({
|
|
1151
1308
|
mode: "private",
|
|
1152
|
-
sender
|
|
1309
|
+
sender,
|
|
1153
1310
|
bucketsFor: config.bucketsFor
|
|
1154
1311
|
});
|
|
1155
1312
|
let system;
|
|
@@ -1168,6 +1325,7 @@ function privateRoutes(deps) {
|
|
|
1168
1325
|
system,
|
|
1169
1326
|
messages,
|
|
1170
1327
|
mode: "private",
|
|
1328
|
+
sender,
|
|
1171
1329
|
model
|
|
1172
1330
|
})) {
|
|
1173
1331
|
await s.write(`data: ${JSON.stringify({ delta })}
|
|
@@ -1177,7 +1335,15 @@ function privateRoutes(deps) {
|
|
|
1177
1335
|
await s.write("event: end\ndata: {}\n\n");
|
|
1178
1336
|
});
|
|
1179
1337
|
}
|
|
1180
|
-
const out = await answerOnce({
|
|
1338
|
+
const out = await answerOnce({
|
|
1339
|
+
answerFn,
|
|
1340
|
+
client,
|
|
1341
|
+
system,
|
|
1342
|
+
messages,
|
|
1343
|
+
mode: "private",
|
|
1344
|
+
sender,
|
|
1345
|
+
model
|
|
1346
|
+
});
|
|
1181
1347
|
return c.json({ reply: out.content });
|
|
1182
1348
|
});
|
|
1183
1349
|
return app;
|
|
@@ -1189,64 +1355,55 @@ import { stream as stream4 } from "hono/streaming";
|
|
|
1189
1355
|
|
|
1190
1356
|
// src/middleware/auth.ts
|
|
1191
1357
|
function createAuthMiddleware(deps) {
|
|
1192
|
-
const { apiKeyManager } = deps;
|
|
1358
|
+
const { apiKeyManager, logger } = deps;
|
|
1193
1359
|
return async function requirePublicKey(c, next) {
|
|
1194
1360
|
const hdr = c.req.header("x-api-key");
|
|
1195
1361
|
if (hdr?.startsWith("session_")) {
|
|
1196
|
-
|
|
1362
|
+
logger.debug(`[Auth] Session key allowed: ${hdr.slice(0, 20)}...`);
|
|
1197
1363
|
await next();
|
|
1198
1364
|
return;
|
|
1199
1365
|
}
|
|
1200
1366
|
if (!apiKeyManager) {
|
|
1201
|
-
|
|
1367
|
+
logger.debug("[Auth] No API key manager configured");
|
|
1202
1368
|
return c.json({ error: "Unauthorized - API key authentication not configured" }, 401);
|
|
1203
1369
|
}
|
|
1204
1370
|
if (!hdr) {
|
|
1205
|
-
|
|
1371
|
+
logger.debug("[Auth] No API key provided");
|
|
1206
1372
|
return c.json({ error: "Unauthorized - Missing x-api-key header" }, 401);
|
|
1207
1373
|
}
|
|
1208
1374
|
try {
|
|
1209
1375
|
const result = await apiKeyManager.verify(hdr);
|
|
1210
1376
|
if (result.valid) {
|
|
1211
1377
|
const payload = result.payload;
|
|
1212
|
-
|
|
1378
|
+
logger.debug(`[Auth] JWT API key authorized: ${payload.name || payload.sub || "unknown"}`);
|
|
1213
1379
|
await next();
|
|
1214
1380
|
return;
|
|
1215
1381
|
}
|
|
1216
|
-
|
|
1382
|
+
logger.debug(`[Auth] JWT verification failed: ${result.error}`);
|
|
1217
1383
|
} catch (error) {
|
|
1218
|
-
|
|
1384
|
+
logger.warn(`[Auth] JWT verification error: ${error}`);
|
|
1219
1385
|
}
|
|
1220
|
-
|
|
1386
|
+
logger.debug(`[Auth] Invalid API key: ${hdr?.slice(0, 20) || "none"}`);
|
|
1221
1387
|
return c.json({ error: "Unauthorized" }, 401);
|
|
1222
1388
|
};
|
|
1223
1389
|
}
|
|
1224
1390
|
|
|
1225
1391
|
// src/middleware/referrer.ts
|
|
1226
|
-
|
|
1227
|
-
function requireReferrer(allowedOrigins) {
|
|
1392
|
+
function requireReferrer(allowedOrigins, demoApiKeys = [], logger = createConsoleLogger()) {
|
|
1228
1393
|
return async (c, next) => {
|
|
1229
1394
|
const apiKey = c.req.header("x-api-key");
|
|
1230
|
-
const isDemoKey = apiKey
|
|
1395
|
+
const isDemoKey = apiKey ? demoApiKeys.includes(apiKey) : false;
|
|
1231
1396
|
const isSessionKey = apiKey?.startsWith("session_");
|
|
1232
1397
|
if (isDemoKey || isSessionKey) {
|
|
1233
1398
|
const referer = c.req.header("referer");
|
|
1234
1399
|
const origin = c.req.header("origin");
|
|
1235
|
-
|
|
1400
|
+
logger.debug(
|
|
1236
1401
|
`[Referrer] Checking ${isSessionKey ? "session" : "demo"} key: ${apiKey?.slice(0, 20)}...`
|
|
1237
1402
|
);
|
|
1238
|
-
|
|
1239
|
-
const isAllowed =
|
|
1240
|
-
if (origin === allowed) return true;
|
|
1241
|
-
if (referer) {
|
|
1242
|
-
if (referer === allowed) return true;
|
|
1243
|
-
if (referer.startsWith(`${allowed}/`)) return true;
|
|
1244
|
-
if (referer.startsWith(`${allowed}?`)) return true;
|
|
1245
|
-
}
|
|
1246
|
-
return false;
|
|
1247
|
-
});
|
|
1403
|
+
logger.debug(`[Referrer] Origin: ${origin || "none"}, Referer: ${referer || "none"}`);
|
|
1404
|
+
const isAllowed = matchesAllowedOrigin(origin, referer, allowedOrigins);
|
|
1248
1405
|
if (!isAllowed) {
|
|
1249
|
-
|
|
1406
|
+
logger.debug(`[Referrer] BLOCKED - not from allowed origins: ${allowedOrigins.join(", ")}`);
|
|
1250
1407
|
return c.json(
|
|
1251
1408
|
{
|
|
1252
1409
|
error: "Demo API keys only work on official demo pages. Please visit our website or use your own API key."
|
|
@@ -1254,29 +1411,29 @@ function requireReferrer(allowedOrigins) {
|
|
|
1254
1411
|
403
|
|
1255
1412
|
);
|
|
1256
1413
|
}
|
|
1257
|
-
|
|
1414
|
+
logger.debug("[Referrer] Allowed - origin matches");
|
|
1258
1415
|
}
|
|
1259
1416
|
await next();
|
|
1260
1417
|
};
|
|
1261
1418
|
}
|
|
1262
1419
|
|
|
1263
1420
|
// src/middleware/session.ts
|
|
1264
|
-
function validateSessionKey() {
|
|
1421
|
+
function validateSessionKey(logger = createConsoleLogger()) {
|
|
1265
1422
|
return async (c, next) => {
|
|
1266
1423
|
const apiKey = c.req.header("x-api-key");
|
|
1267
1424
|
if (apiKey?.startsWith("session_")) {
|
|
1268
|
-
|
|
1425
|
+
logger.debug(`[Session] Validating key: ${apiKey.slice(0, 20)}...`);
|
|
1269
1426
|
const info = getSessionInfo(apiKey);
|
|
1270
1427
|
if (info) {
|
|
1271
|
-
|
|
1428
|
+
logger.debug(
|
|
1272
1429
|
`[Session] Key found - requests: ${info.requests}/${info.maxRequests}, expires: ${new Date(info.expiresAt).toISOString()}`
|
|
1273
1430
|
);
|
|
1274
1431
|
} else {
|
|
1275
|
-
|
|
1432
|
+
logger.debug("[Session] Key not found in session store");
|
|
1276
1433
|
}
|
|
1277
1434
|
const isValid = validateSession(apiKey);
|
|
1278
1435
|
if (!isValid) {
|
|
1279
|
-
|
|
1436
|
+
logger.debug("[Session] REJECTED - expired or quota exceeded");
|
|
1280
1437
|
return c.json(
|
|
1281
1438
|
{
|
|
1282
1439
|
error: "Session expired or quota exceeded. Please refresh the page to get a new session."
|
|
@@ -1284,7 +1441,7 @@ function validateSessionKey() {
|
|
|
1284
1441
|
401
|
|
1285
1442
|
);
|
|
1286
1443
|
}
|
|
1287
|
-
|
|
1444
|
+
logger.debug("[Session] Valid - request count incremented");
|
|
1288
1445
|
}
|
|
1289
1446
|
await next();
|
|
1290
1447
|
};
|
|
@@ -1298,15 +1455,19 @@ function wantsStream2(c) {
|
|
|
1298
1455
|
return acc.includes("text/event-stream");
|
|
1299
1456
|
}
|
|
1300
1457
|
function publicRoutes(deps) {
|
|
1301
|
-
const { client, store, config, prompts } = deps;
|
|
1458
|
+
const { client, store, config, prompts, logger } = deps;
|
|
1302
1459
|
const app = new Hono4();
|
|
1303
1460
|
const requirePublicKey = createAuthMiddleware(deps);
|
|
1304
|
-
const { limitPublic
|
|
1461
|
+
const { limitPublic } = createRateLimiter(config);
|
|
1305
1462
|
const referrerOrigins = config.server?.allowedOrigins?.length ? config.server.allowedOrigins.filter((o) => o !== "*") : [config.bot.publicUrl, "http://localhost:8181", "http://127.0.0.1:8181"];
|
|
1306
|
-
app.use("/api/public/*",
|
|
1463
|
+
app.use("/api/public/*", chatBodyLimit(config.server?.maxRequestBytes));
|
|
1464
|
+
app.use("/api/public/*", validateSessionKey(logger));
|
|
1307
1465
|
app.use("/api/public/*", requirePublicKey);
|
|
1308
|
-
app.use(
|
|
1309
|
-
|
|
1466
|
+
app.use(
|
|
1467
|
+
"/api/public/*",
|
|
1468
|
+
requireReferrer(referrerOrigins, config.rateLimit?.demoApiKeys ?? [], logger)
|
|
1469
|
+
);
|
|
1470
|
+
app.use("/api/public/*", limitPublic());
|
|
1310
1471
|
app.post("/api/public/chat", async (c) => {
|
|
1311
1472
|
const body = await c.req.json().catch(() => ({}));
|
|
1312
1473
|
const normalized = normalizeChatBody(body);
|
|
@@ -1348,7 +1509,8 @@ function publicRoutes(deps) {
|
|
|
1348
1509
|
|
|
1349
1510
|
// src/server.ts
|
|
1350
1511
|
async function createServer(config) {
|
|
1351
|
-
|
|
1512
|
+
const logger = resolveLogger(config.logger, config.logLevel);
|
|
1513
|
+
logger.info(`\u{1F680} Starting ${config.bot.name}...`);
|
|
1352
1514
|
const knowledgeDir = config.knowledgeDir || "./config/knowledge";
|
|
1353
1515
|
const promptsDir = config.promptsDir || "./config/prompts";
|
|
1354
1516
|
const publicDir = config.publicDir || "./public";
|
|
@@ -1367,24 +1529,26 @@ async function createServer(config) {
|
|
|
1367
1529
|
});
|
|
1368
1530
|
const store = new VectorStore(client, {
|
|
1369
1531
|
databaseClient: db,
|
|
1370
|
-
knowledgeDir
|
|
1532
|
+
knowledgeDir,
|
|
1533
|
+
logger
|
|
1371
1534
|
});
|
|
1372
1535
|
await store.build();
|
|
1373
|
-
|
|
1536
|
+
logger.info("\u2705 Knowledge base ready");
|
|
1374
1537
|
const prompts = new PromptLoader(promptsDir, config.bot);
|
|
1375
1538
|
let apiKeyManager;
|
|
1376
|
-
const secret = process.env.CHATTER_SECRET
|
|
1539
|
+
const secret = config.auth?.secret || process.env.CHATTER_SECRET;
|
|
1377
1540
|
if (secret) {
|
|
1378
1541
|
apiKeyManager = new ApiKeyManager(secret);
|
|
1379
|
-
|
|
1542
|
+
logger.info("\u2705 API key manager initialized");
|
|
1380
1543
|
} else if (config.auth?.apiKeyManager) {
|
|
1381
1544
|
apiKeyManager = config.auth.apiKeyManager;
|
|
1382
|
-
|
|
1545
|
+
logger.info("\u2705 Custom API key manager configured");
|
|
1383
1546
|
}
|
|
1384
1547
|
const app = new Hono5();
|
|
1385
1548
|
if (config.server?.cors !== false) {
|
|
1386
1549
|
app.use("*", cors(config.server?.allowedOrigins));
|
|
1387
1550
|
}
|
|
1551
|
+
app.use("*", securityHeaders(config));
|
|
1388
1552
|
app.get("/healthz", (c) => c.text("ok"));
|
|
1389
1553
|
app.get(
|
|
1390
1554
|
"/config",
|
|
@@ -1397,12 +1561,20 @@ async function createServer(config) {
|
|
|
1397
1561
|
chat: config.chat || {}
|
|
1398
1562
|
})
|
|
1399
1563
|
);
|
|
1400
|
-
|
|
1401
|
-
|
|
1564
|
+
let serveStatic = null;
|
|
1565
|
+
if (!headless && (staticDir || publicDir)) {
|
|
1566
|
+
try {
|
|
1567
|
+
serveStatic = await loadServeStatic();
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
logger.error(`\u274C Static assets disabled: ${error.message}`);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
if (serveStatic && staticDir) {
|
|
1573
|
+
const relativePath = relative(process.cwd(), staticDir);
|
|
1402
1574
|
app.get("/chatter.js", serveStatic({ path: `${relativePath}/chatter.js` }));
|
|
1403
1575
|
app.get("/chatter.css", serveStatic({ path: `${relativePath}/chatter.css` }));
|
|
1404
1576
|
}
|
|
1405
|
-
if (
|
|
1577
|
+
if (serveStatic && publicDir) {
|
|
1406
1578
|
app.get("/", serveStatic({ path: `${publicDir}/index.html` }));
|
|
1407
1579
|
app.get("/chat", serveStatic({ path: `${publicDir}/chat.html` }));
|
|
1408
1580
|
app.get("/private", serveStatic({ path: `${publicDir}/private.html` }));
|
|
@@ -1444,8 +1616,17 @@ async function createServer(config) {
|
|
|
1444
1616
|
app.get("/demo/chatbot", serveStatic({ path: `${publicDir}/demo-chatbot.html` }));
|
|
1445
1617
|
app.get("/demo/react", serveStatic({ path: `${publicDir}/react-demo.html` }));
|
|
1446
1618
|
}
|
|
1447
|
-
const senders = createSenderRegistry();
|
|
1448
|
-
const deps = {
|
|
1619
|
+
const senders = createSenderRegistry(logger);
|
|
1620
|
+
const deps = {
|
|
1621
|
+
client,
|
|
1622
|
+
store,
|
|
1623
|
+
db,
|
|
1624
|
+
config,
|
|
1625
|
+
prompts,
|
|
1626
|
+
apiKeyManager,
|
|
1627
|
+
senders,
|
|
1628
|
+
logger
|
|
1629
|
+
};
|
|
1449
1630
|
if (enablePublic) {
|
|
1450
1631
|
app.route("/", publicRoutes(deps));
|
|
1451
1632
|
}
|
|
@@ -1467,13 +1648,13 @@ async function createServer(config) {
|
|
|
1467
1648
|
try {
|
|
1468
1649
|
await channel.start(deps);
|
|
1469
1650
|
started.push(channel);
|
|
1470
|
-
|
|
1651
|
+
logger.info(`\u2705 Channel "${channel.name}" started`);
|
|
1471
1652
|
} catch (error) {
|
|
1472
|
-
|
|
1653
|
+
logger.error(`\u274C Channel "${channel.name}" failed to start:`, error);
|
|
1473
1654
|
}
|
|
1474
1655
|
}
|
|
1475
1656
|
}
|
|
1476
|
-
|
|
1657
|
+
logger.info(`\u2705 ${config.bot.name} server ready`);
|
|
1477
1658
|
return Object.assign(app, {
|
|
1478
1659
|
// Scoped to this call's started channels, not global — two servers in
|
|
1479
1660
|
// one process each stop only their own. A channel's `stop()` throwing
|
|
@@ -1485,7 +1666,7 @@ async function createServer(config) {
|
|
|
1485
1666
|
try {
|
|
1486
1667
|
await channel.stop?.();
|
|
1487
1668
|
} catch (error) {
|
|
1488
|
-
|
|
1669
|
+
logger.error(`Channel "${channel.name}" failed to stop:`, error);
|
|
1489
1670
|
}
|
|
1490
1671
|
})
|
|
1491
1672
|
);
|