@frankieai/server 0.2.5 → 0.2.6
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/dist/{chunk-SQ5NJKFE.js → chunk-EYR3BV7U.js} +505 -35
- package/dist/chunk-EYR3BV7U.js.map +1 -0
- package/dist/{chunk-HPYT6ZIU.js → chunk-WEHJH4FL.js} +610 -54
- package/dist/chunk-WEHJH4FL.js.map +1 -0
- package/dist/control-server.js +5 -5
- package/dist/control-server.js.map +1 -1
- package/dist/index.d.ts +180 -2
- package/dist/index.js +2 -2
- package/dist/server.js +2 -2
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
- package/dist/chunk-HPYT6ZIU.js.map +0 -1
- package/dist/chunk-SQ5NJKFE.js.map +0 -1
|
@@ -26,7 +26,10 @@ var TokenService = class {
|
|
|
26
26
|
const options = {
|
|
27
27
|
...this.auth.jwtIssuer ? { issuer: this.auth.jwtIssuer } : {},
|
|
28
28
|
...this.auth.jwtAudience ? { audience: this.auth.jwtAudience } : {},
|
|
29
|
-
clockTolerance: 30
|
|
29
|
+
clockTolerance: 30,
|
|
30
|
+
// A token with no expiry would be valid forever once leaked. Every
|
|
31
|
+
// token endpoint Frankie generates or documents sets one.
|
|
32
|
+
requiredClaims: ["exp"]
|
|
30
33
|
};
|
|
31
34
|
let payload;
|
|
32
35
|
try {
|
|
@@ -193,9 +196,26 @@ var MemoryRateLimiter = class {
|
|
|
193
196
|
}
|
|
194
197
|
}
|
|
195
198
|
};
|
|
199
|
+
function clientIp(header, trustedHops = trustedProxyHops()) {
|
|
200
|
+
const forwarded = header("x-forwarded-for");
|
|
201
|
+
if (forwarded && trustedHops > 0) {
|
|
202
|
+
const hops = forwarded.split(",").map((entry2) => entry2.trim()).filter(Boolean);
|
|
203
|
+
const entry = hops[Math.max(0, hops.length - trustedHops)];
|
|
204
|
+
if (entry) return entry;
|
|
205
|
+
}
|
|
206
|
+
return header("cf-connecting-ip") ?? header("x-real-ip") ?? "unknown";
|
|
207
|
+
}
|
|
208
|
+
function trustedProxyHops() {
|
|
209
|
+
const value = Number(process.env.FRANKIE_TRUSTED_PROXY_HOPS ?? 1);
|
|
210
|
+
return Number.isInteger(value) && value >= 0 && value <= 10 ? value : 1;
|
|
211
|
+
}
|
|
196
212
|
|
|
197
213
|
// src/sessions.ts
|
|
198
214
|
import { randomUUID } from "crypto";
|
|
215
|
+
function isTurnRunning(session, leaseMs, now = Date.now()) {
|
|
216
|
+
if (!session.busy) return false;
|
|
217
|
+
return now - (session.busySince ?? session.lastActiveAt) < leaseMs;
|
|
218
|
+
}
|
|
199
219
|
var MemorySessionStore = class {
|
|
200
220
|
constructor(ttlMs = 6 * 60 * 60 * 1e3) {
|
|
201
221
|
this.ttlMs = ttlMs;
|
|
@@ -249,6 +269,7 @@ function newSessionId() {
|
|
|
249
269
|
}
|
|
250
270
|
|
|
251
271
|
// src/tenants.ts
|
|
272
|
+
import { resolvePersonaConfig } from "@frankieai/core";
|
|
252
273
|
var TenantRegistry = class _TenantRegistry {
|
|
253
274
|
constructor(store) {
|
|
254
275
|
this.store = store;
|
|
@@ -300,7 +321,14 @@ var SingleTenantStore = class {
|
|
|
300
321
|
}
|
|
301
322
|
resolved;
|
|
302
323
|
async byPublishableKey(key) {
|
|
303
|
-
|
|
324
|
+
const base = this.resolved.config;
|
|
325
|
+
if (safeEqual(key, base.auth.publishableKey)) return this.resolved;
|
|
326
|
+
for (const persona of base.personas) {
|
|
327
|
+
if (!safeEqual(key, persona.publishableKey)) continue;
|
|
328
|
+
const config = resolvePersonaConfig(base, persona.publishableKey);
|
|
329
|
+
return config ? { ...this.resolved, config } : null;
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
304
332
|
}
|
|
305
333
|
};
|
|
306
334
|
var MemoryTenantStore = class {
|
|
@@ -308,12 +336,17 @@ var MemoryTenantStore = class {
|
|
|
308
336
|
constructor(tenants = []) {
|
|
309
337
|
for (const tenant of tenants) this.add(tenant);
|
|
310
338
|
}
|
|
311
|
-
/** Register (or replace) a tenant
|
|
339
|
+
/** Register (or replace) a tenant, under its base key and each persona's key. */
|
|
312
340
|
add(resolved) {
|
|
313
341
|
this.byKey.set(resolved.config.auth.publishableKey, resolved);
|
|
342
|
+
for (const persona of resolved.config.personas) this.byKey.set(persona.publishableKey, resolved);
|
|
314
343
|
}
|
|
315
344
|
async byPublishableKey(key) {
|
|
316
|
-
|
|
345
|
+
const resolved = this.byKey.get(key);
|
|
346
|
+
if (!resolved) return null;
|
|
347
|
+
const config = resolvePersonaConfig(resolved.config, key);
|
|
348
|
+
if (!config) return null;
|
|
349
|
+
return config === resolved.config ? resolved : { ...resolved, config };
|
|
317
350
|
}
|
|
318
351
|
};
|
|
319
352
|
|
|
@@ -456,7 +489,8 @@ async function inspectApiCapabilities(source, options = {}) {
|
|
|
456
489
|
if (options.policy && !evaluateToolPolicy(toolName, options.policy).allowed) continue;
|
|
457
490
|
const summary = typeof operation["summary"] === "string" ? operation["summary"] : void 0;
|
|
458
491
|
const authGap = httpMethod !== "GET" && effectiveSecurity(doc, operation).length > 0 && !hasPerUserAuth;
|
|
459
|
-
|
|
492
|
+
const inputs = describeInputs(doc, collectParameters(doc, item, operation), collectRequestBody(doc, operation));
|
|
493
|
+
capabilities.push({ operationId, method: httpMethod, path, ...summary ? { summary } : {}, ...inputs ? { inputs } : {}, authGap });
|
|
460
494
|
}
|
|
461
495
|
}
|
|
462
496
|
return capabilities;
|
|
@@ -537,6 +571,11 @@ ${retrySafetyNote(method, doc, item, operation)}`,
|
|
|
537
571
|
`"${operationId}" runs in the user's browser and has no server base URL, so it can't run over the hosted MCP endpoint or a headless caller. Set a baseUrl (and a user-token forwarding header) to expose it that way too. Report this to the user.`
|
|
538
572
|
);
|
|
539
573
|
}
|
|
574
|
+
if (!READ_METHODS.has(method.toUpperCase()) && options.session?.user.anonymous && !options.session.userToken) {
|
|
575
|
+
throw new Error(
|
|
576
|
+
`"${operationId}" changes data, and this visitor isn't signed in, so it can't run. Tell the user to sign in first, or offer to hand the request to the team.`
|
|
577
|
+
);
|
|
578
|
+
}
|
|
540
579
|
const input = enforceIdentityParams(params, isRecord(args) ? args : {}, options.session);
|
|
541
580
|
enforceResourceScope(scopeRules, input, options.session);
|
|
542
581
|
const url = buildUrl(opBaseUrl, path, params, input, securityQuery);
|
|
@@ -652,7 +691,7 @@ function collectParameters(doc, item, operation) {
|
|
|
652
691
|
const location = resolved["in"];
|
|
653
692
|
if (typeof name !== "string") continue;
|
|
654
693
|
if (location !== "path" && location !== "query") continue;
|
|
655
|
-
const schema =
|
|
694
|
+
const schema = inlineSchema(doc, resolved["schema"]);
|
|
656
695
|
params.push({
|
|
657
696
|
name,
|
|
658
697
|
location,
|
|
@@ -670,9 +709,108 @@ function collectRequestBody(doc, operation) {
|
|
|
670
709
|
if (!isRecord(content)) return void 0;
|
|
671
710
|
const json = content["application/json"];
|
|
672
711
|
if (!isRecord(json)) return void 0;
|
|
673
|
-
const schema =
|
|
712
|
+
const schema = inlineSchema(doc, json["schema"]);
|
|
674
713
|
return isRecord(schema) ? schema : void 0;
|
|
675
714
|
}
|
|
715
|
+
var INPUT_DESCRIPTION_CHARS = 100;
|
|
716
|
+
var INPUTS_LINE_CHARS = 900;
|
|
717
|
+
var NESTED_FIELDS = 8;
|
|
718
|
+
function describeInputs(doc, params, body) {
|
|
719
|
+
const entries = [];
|
|
720
|
+
for (const param of params) {
|
|
721
|
+
entries.push(describeInput(doc, param.name, param.required, { ...param.schema, ...param.description ? { description: param.description } : {} }));
|
|
722
|
+
}
|
|
723
|
+
entries.push(...describeProperties(doc, body));
|
|
724
|
+
if (entries.length === 0) return void 0;
|
|
725
|
+
const line = entries.join("; ");
|
|
726
|
+
return line.length > INPUTS_LINE_CHARS ? `${line.slice(0, INPUTS_LINE_CHARS - 1)}\u2026` : line;
|
|
727
|
+
}
|
|
728
|
+
function describeSchemaInputs(schema) {
|
|
729
|
+
const entries = describeProperties({}, isRecord(schema) ? schema : void 0);
|
|
730
|
+
if (entries.length === 0) return void 0;
|
|
731
|
+
const line = entries.join("; ");
|
|
732
|
+
return line.length > INPUTS_LINE_CHARS ? `${line.slice(0, INPUTS_LINE_CHARS - 1)}\u2026` : line;
|
|
733
|
+
}
|
|
734
|
+
function describeProperties(doc, schema) {
|
|
735
|
+
const properties = schema?.["properties"];
|
|
736
|
+
if (!isRecord(properties)) return [];
|
|
737
|
+
const required = new Set(Array.isArray(schema?.["required"]) ? schema["required"].map(String) : []);
|
|
738
|
+
const entries = [];
|
|
739
|
+
for (const [name, child] of Object.entries(properties)) {
|
|
740
|
+
const resolved = resolveRef(doc, child);
|
|
741
|
+
if (isRecord(resolved) && resolved["readOnly"] === true) continue;
|
|
742
|
+
entries.push(describeInput(doc, name, required.has(name), isRecord(resolved) ? resolved : {}));
|
|
743
|
+
}
|
|
744
|
+
return entries;
|
|
745
|
+
}
|
|
746
|
+
function describeInput(doc, name, required, schema) {
|
|
747
|
+
const detail = [];
|
|
748
|
+
const description = typeof schema["description"] === "string" ? schema["description"].replace(/\s+/g, " ").trim() : "";
|
|
749
|
+
if (description) detail.push(clip(description, INPUT_DESCRIPTION_CHARS));
|
|
750
|
+
const allowed = Array.isArray(schema["enum"]) ? schema["enum"] : void 0;
|
|
751
|
+
const type = typeof schema["type"] === "string" ? schema["type"] : void 0;
|
|
752
|
+
const items = isRecord(schema["items"]) ? resolveRef(doc, schema["items"]) : void 0;
|
|
753
|
+
const nested = nestedFields(doc, type === "array" && isRecord(items) ? items : schema);
|
|
754
|
+
if (allowed?.length) detail.push(`one of ${allowed.map(String).join(", ")}`);
|
|
755
|
+
else if (nested) detail.push(type === "array" ? `list of {${nested}}` : `{${nested}}`);
|
|
756
|
+
else if (!description) {
|
|
757
|
+
const itemType = isRecord(items) && typeof items["type"] === "string" ? items["type"] : void 0;
|
|
758
|
+
if (type) detail.push(type === "array" && itemType ? `list of ${itemType}` : type);
|
|
759
|
+
}
|
|
760
|
+
if (typeof schema["format"] === "string" && !allowed) detail.push(`format ${schema["format"]}`);
|
|
761
|
+
const range = rangeOf(schema, type);
|
|
762
|
+
if (range) detail.push(range);
|
|
763
|
+
if (schema["default"] !== void 0 && typeof schema["default"] !== "object") detail.push(`default ${String(schema["default"])}`);
|
|
764
|
+
const example = schema["example"] ?? (Array.isArray(schema["examples"]) ? schema["examples"][0] : void 0);
|
|
765
|
+
if (example !== void 0 && typeof example !== "object" && !allowed) detail.push(`e.g. ${clip(String(example), 40)}`);
|
|
766
|
+
return `${name}${required ? " (required)" : ""}${detail.length ? `: ${detail.join(", ")}` : ""}`;
|
|
767
|
+
}
|
|
768
|
+
function nestedFields(doc, schema) {
|
|
769
|
+
const properties = schema["properties"];
|
|
770
|
+
if (!isRecord(properties)) return void 0;
|
|
771
|
+
const required = new Set(Array.isArray(schema["required"]) ? schema["required"].map(String) : []);
|
|
772
|
+
const names = Object.entries(properties).filter(([, child]) => !(isRecord(resolveRef(doc, child)) && resolveRef(doc, child)["readOnly"] === true)).map(([field]) => field + (required.has(field) ? " (required)" : ""));
|
|
773
|
+
if (names.length === 0) return void 0;
|
|
774
|
+
return names.slice(0, NESTED_FIELDS).join(", ") + (names.length > NESTED_FIELDS ? ", \u2026" : "");
|
|
775
|
+
}
|
|
776
|
+
function rangeOf(schema, type) {
|
|
777
|
+
const bounds = (min, max, unit) => {
|
|
778
|
+
const lo = typeof min === "number" ? min : void 0;
|
|
779
|
+
const hi = typeof max === "number" ? max : void 0;
|
|
780
|
+
if (lo === void 0 && hi === void 0) return void 0;
|
|
781
|
+
if (lo !== void 0 && hi !== void 0) return `${lo}\u2013${hi}${unit}`;
|
|
782
|
+
return lo !== void 0 ? `at least ${lo}${unit}` : `at most ${hi}${unit}`;
|
|
783
|
+
};
|
|
784
|
+
if (type === "string") return bounds(schema["minLength"], schema["maxLength"], " characters");
|
|
785
|
+
if (type === "array") return bounds(schema["minItems"], schema["maxItems"], " items");
|
|
786
|
+
if (type === "number" || type === "integer") return bounds(schema["minimum"], schema["maximum"], "");
|
|
787
|
+
return void 0;
|
|
788
|
+
}
|
|
789
|
+
function clip(text, max) {
|
|
790
|
+
return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
|
|
791
|
+
}
|
|
792
|
+
var SCHEMA_DATA_KEYWORDS = /* @__PURE__ */ new Set(["enum", "const", "default", "example", "examples"]);
|
|
793
|
+
function inlineSchema(doc, node, trail = []) {
|
|
794
|
+
if (Array.isArray(node)) return node.map((entry) => inlineSchema(doc, entry, trail));
|
|
795
|
+
if (!isRecord(node)) return node;
|
|
796
|
+
const ref = node["$ref"];
|
|
797
|
+
if (typeof ref === "string") {
|
|
798
|
+
if (!ref.startsWith("#/") || trail.includes(ref) || trail.length > 32) return {};
|
|
799
|
+
const target = resolveRef(doc, node);
|
|
800
|
+
return isRecord(target) ? inlineSchema(doc, target, [...trail, ref]) : {};
|
|
801
|
+
}
|
|
802
|
+
const out = {};
|
|
803
|
+
for (const [key, value] of Object.entries(node)) {
|
|
804
|
+
out[key] = SCHEMA_DATA_KEYWORDS.has(key) ? value : inlineSchema(doc, value, trail);
|
|
805
|
+
}
|
|
806
|
+
if (out["nullable"] === true) {
|
|
807
|
+
delete out["nullable"];
|
|
808
|
+
const type = out["type"];
|
|
809
|
+
if (typeof type === "string" && type !== "null") out["type"] = [type, "null"];
|
|
810
|
+
else if (Array.isArray(type) && !type.includes("null")) out["type"] = [...type, "null"];
|
|
811
|
+
}
|
|
812
|
+
return out;
|
|
813
|
+
}
|
|
676
814
|
function toolSchema(params, body) {
|
|
677
815
|
const properties = {};
|
|
678
816
|
const required = [];
|
|
@@ -691,12 +829,17 @@ function toolSchema(params, body) {
|
|
|
691
829
|
additionalProperties: false
|
|
692
830
|
};
|
|
693
831
|
}
|
|
832
|
+
function encodePathParam(name, value) {
|
|
833
|
+
const raw = String(value ?? "");
|
|
834
|
+
if (raw === "." || raw === "..") throw new Error(`"${raw}" isn't a valid value for the path parameter "${name}".`);
|
|
835
|
+
return encodeURIComponent(raw);
|
|
836
|
+
}
|
|
837
|
+
var READ_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
694
838
|
function buildUrl(baseUrl, path, params, input, extraQuery = {}) {
|
|
695
839
|
let resolved = path;
|
|
696
840
|
for (const param of params) {
|
|
697
841
|
if (param.location !== "path") continue;
|
|
698
|
-
|
|
699
|
-
resolved = resolved.replace(`{${param.name}}`, encodeURIComponent(String(value ?? "")));
|
|
842
|
+
resolved = resolved.replace(`{${param.name}}`, encodePathParam(param.name, input[param.name]));
|
|
700
843
|
}
|
|
701
844
|
const url = new URL(`${baseUrl}${resolved.startsWith("/") ? "" : "/"}${resolved}`);
|
|
702
845
|
for (const [name, value] of Object.entries(extraQuery)) url.searchParams.set(name, value);
|
|
@@ -716,7 +859,7 @@ function buildRelativePath(path, params, input) {
|
|
|
716
859
|
let resolved = path;
|
|
717
860
|
for (const param of params) {
|
|
718
861
|
if (param.location !== "path") continue;
|
|
719
|
-
resolved = resolved.replace(`{${param.name}}`,
|
|
862
|
+
resolved = resolved.replace(`{${param.name}}`, encodePathParam(param.name, input[param.name]));
|
|
720
863
|
}
|
|
721
864
|
const query = new URLSearchParams();
|
|
722
865
|
for (const param of params) {
|
|
@@ -1237,6 +1380,161 @@ function isRecord2(value) {
|
|
|
1237
1380
|
return typeof value === "object" && value !== null;
|
|
1238
1381
|
}
|
|
1239
1382
|
|
|
1383
|
+
// src/agent/knowledge-tools.ts
|
|
1384
|
+
import { Type as Type4 } from "@earendil-works/pi-ai";
|
|
1385
|
+
|
|
1386
|
+
// src/agent/embeddings.ts
|
|
1387
|
+
var DEFAULT_EMBEDDING_MODEL = "nomic-ai/nomic-embed-text-v1.5";
|
|
1388
|
+
var DEFAULT_BASE_URL = "https://api.fireworks.ai/inference/v1";
|
|
1389
|
+
function resolveEmbeddingConfig(config, apiKey) {
|
|
1390
|
+
const base = (config.model.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
1391
|
+
return { endpoint: base + "/embeddings", apiKey, model: DEFAULT_EMBEDDING_MODEL };
|
|
1392
|
+
}
|
|
1393
|
+
async function embedTexts(texts, config, fetchImpl = fetch, batchSize = 64) {
|
|
1394
|
+
const out = [];
|
|
1395
|
+
for (let i = 0; i < texts.length; i += batchSize) {
|
|
1396
|
+
const batch = texts.slice(i, i + batchSize);
|
|
1397
|
+
const response = await fetchImpl(config.endpoint, {
|
|
1398
|
+
method: "POST",
|
|
1399
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${config.apiKey}` },
|
|
1400
|
+
body: JSON.stringify({ model: config.model, input: batch }),
|
|
1401
|
+
signal: AbortSignal.timeout(3e4)
|
|
1402
|
+
});
|
|
1403
|
+
if (!response.ok) {
|
|
1404
|
+
const detail = await response.text().catch(() => "");
|
|
1405
|
+
throw new Error(`Embedding request failed (HTTP ${response.status})${detail ? ": " + detail.slice(0, 200) : ""}`);
|
|
1406
|
+
}
|
|
1407
|
+
const body = await response.json();
|
|
1408
|
+
const vectors = body.data ?? [];
|
|
1409
|
+
if (vectors.length !== batch.length) throw new Error(`Embedding provider returned ${vectors.length} vectors for ${batch.length} inputs.`);
|
|
1410
|
+
for (const entry of vectors) {
|
|
1411
|
+
if (!Array.isArray(entry.embedding)) throw new Error("Embedding provider returned a malformed vector.");
|
|
1412
|
+
out.push(entry.embedding);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
return out;
|
|
1416
|
+
}
|
|
1417
|
+
function cosineSimilarity(a, b) {
|
|
1418
|
+
const length = Math.min(a.length, b.length);
|
|
1419
|
+
let dot = 0;
|
|
1420
|
+
let normA = 0;
|
|
1421
|
+
let normB = 0;
|
|
1422
|
+
for (let i = 0; i < length; i++) {
|
|
1423
|
+
dot += a[i] * b[i];
|
|
1424
|
+
normA += a[i] * a[i];
|
|
1425
|
+
normB += b[i] * b[i];
|
|
1426
|
+
}
|
|
1427
|
+
if (normA === 0 || normB === 0) return 0;
|
|
1428
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// src/agent/knowledge-tools.ts
|
|
1432
|
+
var KNOWLEDGE_TOOL_PREFIX = "frankie__docs__";
|
|
1433
|
+
function isKnowledgeTool(name) {
|
|
1434
|
+
return name.startsWith(KNOWLEDGE_TOOL_PREFIX);
|
|
1435
|
+
}
|
|
1436
|
+
function buildKnowledgeTools(config, deps) {
|
|
1437
|
+
if (!config.knowledgeBase.enabled || !deps) return [];
|
|
1438
|
+
const embed = deps.embedImpl ?? embedTexts;
|
|
1439
|
+
return [
|
|
1440
|
+
{
|
|
1441
|
+
name: KNOWLEDGE_TOOL_PREFIX + "search",
|
|
1442
|
+
label: "Search the docs",
|
|
1443
|
+
description: "Search the workspace's documentation for passages relevant to a question, and answer from what it returns. Always call this before answering a question that the docs would cover; prefer the returned passages over your own memory, cite the source URL, and if nothing relevant comes back, say you couldn't find it in the docs rather than guessing.",
|
|
1444
|
+
parameters: Type4.Unsafe({
|
|
1445
|
+
type: "object",
|
|
1446
|
+
required: ["query"],
|
|
1447
|
+
properties: {
|
|
1448
|
+
query: { type: "string", description: "What to look up, in natural language." }
|
|
1449
|
+
}
|
|
1450
|
+
}),
|
|
1451
|
+
async execute(_id, args) {
|
|
1452
|
+
const query = String((typeof args === "object" && args !== null ? args["query"] : "") ?? "").trim();
|
|
1453
|
+
if (!query) return { content: [{ type: "text", text: "Provide a search query." }], details: null };
|
|
1454
|
+
let hits;
|
|
1455
|
+
try {
|
|
1456
|
+
const [vector] = await embed([query], deps.embedding);
|
|
1457
|
+
hits = await deps.store.search(config.tenantId, vector ?? [], 6);
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
return { content: [{ type: "text", text: `The docs search is unavailable right now: ${error instanceof Error ? error.message : "unknown error"}. Tell the user you couldn't check the docs.` }], details: null };
|
|
1460
|
+
}
|
|
1461
|
+
const relevant = hits.filter((hit) => hit.score > 0.2);
|
|
1462
|
+
if (relevant.length === 0) {
|
|
1463
|
+
return { content: [{ type: "text", text: "No relevant passages found in the documentation. Tell the user you couldn't find this in the docs." }], details: null };
|
|
1464
|
+
}
|
|
1465
|
+
const rendered = relevant.map((hit, index) => `[${index + 1}] ${hit.title} \u2014 ${hit.url}
|
|
1466
|
+
${hit.text}`).join("\n\n---\n\n");
|
|
1467
|
+
return {
|
|
1468
|
+
content: [{ type: "text", text: `Relevant documentation passages (cite the source URL when you use one):
|
|
1469
|
+
|
|
1470
|
+
${rendered}` }],
|
|
1471
|
+
details: null
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
];
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// src/agent/escalation-tools.ts
|
|
1479
|
+
import { Type as Type5 } from "@earendil-works/pi-ai";
|
|
1480
|
+
var ESCALATION_TOOL_PREFIX = "frankie__escalate__";
|
|
1481
|
+
function isEscalationTool(name) {
|
|
1482
|
+
return name.startsWith(ESCALATION_TOOL_PREFIX);
|
|
1483
|
+
}
|
|
1484
|
+
function newId() {
|
|
1485
|
+
return "esc_" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
1486
|
+
}
|
|
1487
|
+
function buildEscalationTools(config, session, sink) {
|
|
1488
|
+
if (!config.features.escalation.enabled) return [];
|
|
1489
|
+
return [
|
|
1490
|
+
{
|
|
1491
|
+
name: ESCALATION_TOOL_PREFIX + "contact",
|
|
1492
|
+
label: "Escalate to a person",
|
|
1493
|
+
description: "Hand this conversation to the workspace's support team. Use it when you cannot fully resolve what the visitor needs, or when they ask to talk to a person. First ask for the visitor's email if you don't already have it, and write a short summary of what they need and what you already tried. This notifies the team; after calling it, tell the visitor a human will follow up. It grants no other access and returns no data from your systems.",
|
|
1494
|
+
parameters: Type5.Unsafe({
|
|
1495
|
+
type: "object",
|
|
1496
|
+
required: ["summary", "reason"],
|
|
1497
|
+
properties: {
|
|
1498
|
+
summary: { type: "string", description: "A short recap of what the visitor needs and what you already tried." },
|
|
1499
|
+
reason: { type: "string", description: `Why you're escalating (e.g. "visitor asked for a human", "outside my knowledge").` },
|
|
1500
|
+
email: { type: "string", description: "The visitor's email, if they provided one." },
|
|
1501
|
+
name: { type: "string", description: "The visitor's name, if they provided one." }
|
|
1502
|
+
}
|
|
1503
|
+
}),
|
|
1504
|
+
async execute(_id, args) {
|
|
1505
|
+
const record = typeof args === "object" && args !== null ? args : {};
|
|
1506
|
+
const summary = String(record["summary"] ?? "").trim();
|
|
1507
|
+
const reason = String(record["reason"] ?? "").trim();
|
|
1508
|
+
if (!summary || !reason) {
|
|
1509
|
+
return { content: [{ type: "text", text: "Provide both a summary and a reason before escalating." }], details: null };
|
|
1510
|
+
}
|
|
1511
|
+
const email = typeof record["email"] === "string" ? record["email"].trim() : "";
|
|
1512
|
+
const name = typeof record["name"] === "string" ? record["name"].trim() : "";
|
|
1513
|
+
const event = {
|
|
1514
|
+
id: newId(),
|
|
1515
|
+
at: Date.now(),
|
|
1516
|
+
reason: reason.slice(0, 500),
|
|
1517
|
+
summary: summary.slice(0, 2e3),
|
|
1518
|
+
...email ? { email: email.slice(0, 320) } : {},
|
|
1519
|
+
...name ? { name: name.slice(0, 120) } : {},
|
|
1520
|
+
tenantId: config.tenantId,
|
|
1521
|
+
conversationId: session.id,
|
|
1522
|
+
userId: session.user.id
|
|
1523
|
+
};
|
|
1524
|
+
try {
|
|
1525
|
+
await sink(event);
|
|
1526
|
+
} catch {
|
|
1527
|
+
return { content: [{ type: "text", text: "The team has been notified with your summary. A human will follow up." }], details: null };
|
|
1528
|
+
}
|
|
1529
|
+
return {
|
|
1530
|
+
content: [{ type: "text", text: `Escalated to the team${email ? " (they'll reach out to " + email + ")" : ""}. Tell the visitor a person will follow up shortly.` }],
|
|
1531
|
+
details: null
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
];
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1240
1538
|
// src/agent/tools.ts
|
|
1241
1539
|
async function collectTools(options) {
|
|
1242
1540
|
const { config, session } = options;
|
|
@@ -1271,7 +1569,9 @@ async function collectTools(options) {
|
|
|
1271
1569
|
accountApiUrl,
|
|
1272
1570
|
...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
|
|
1273
1571
|
});
|
|
1274
|
-
const
|
|
1572
|
+
const knowledge = buildKnowledgeTools(config, options.knowledge);
|
|
1573
|
+
const escalation = options.onEscalation ? buildEscalationTools(config, session, options.onEscalation) : [];
|
|
1574
|
+
const tools = dedupe([...platform, ...knowledge, ...escalation, ...mcp.tools, ...api.tools], options.onWarning);
|
|
1275
1575
|
return {
|
|
1276
1576
|
tools,
|
|
1277
1577
|
failed: [...mcp.failed, ...api.failed],
|
|
@@ -1303,11 +1603,14 @@ function dedupe(tools, onWarning) {
|
|
|
1303
1603
|
// src/host-mcp.ts
|
|
1304
1604
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
1305
1605
|
import { Hono } from "hono";
|
|
1606
|
+
import { bodyLimit } from "hono/body-limit";
|
|
1306
1607
|
var PROTOCOL_VERSION = "2025-06-18";
|
|
1307
1608
|
var SERVER_INFO = { name: "frankie-hosted-mcp", version: "0.1.0" };
|
|
1609
|
+
var MAX_MCP_BATCH = 20;
|
|
1308
1610
|
function createMcpHostApp(options) {
|
|
1309
1611
|
const registry = options.tenants instanceof TenantRegistry ? options.tenants : new TenantRegistry(options.tenants);
|
|
1310
1612
|
const log = options.logger ?? ((event) => console.log(JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...event })));
|
|
1613
|
+
const limiter = options.rateLimiter ?? new MemoryRateLimiter();
|
|
1311
1614
|
const app = new Hono();
|
|
1312
1615
|
app.get(
|
|
1313
1616
|
"/:pk",
|
|
@@ -1315,6 +1618,7 @@ function createMcpHostApp(options) {
|
|
|
1315
1618
|
allow: "POST"
|
|
1316
1619
|
})
|
|
1317
1620
|
);
|
|
1621
|
+
app.use("/:pk", bodyLimit({ maxSize: 1024 * 1024, onError: (c) => c.json(rpcError(null, -32600, "Request too large."), 413) }));
|
|
1318
1622
|
app.post("/:pk", async (c) => {
|
|
1319
1623
|
const pk = c.req.param("pk");
|
|
1320
1624
|
let tenant;
|
|
@@ -1339,6 +1643,14 @@ function createMcpHostApp(options) {
|
|
|
1339
1643
|
return c.json(rpcError(null, -32700, "Parse error: request body must be JSON."), 200);
|
|
1340
1644
|
}
|
|
1341
1645
|
const requests = Array.isArray(payload) ? payload : [payload];
|
|
1646
|
+
if (requests.length > MAX_MCP_BATCH) {
|
|
1647
|
+
return c.json(rpcError(null, -32600, `Send at most ${MAX_MCP_BATCH} requests in one batch.`), 200);
|
|
1648
|
+
}
|
|
1649
|
+
const caller = session.user.anonymous ? "ip:" + clientIp((name) => c.req.header(name)) : "user:" + session.user.id;
|
|
1650
|
+
const { messagesPerWindow, windowSeconds } = tenant.config.limits;
|
|
1651
|
+
if (!await limiter.take(`mcp:${tenant.config.tenantId}:${caller}`, messagesPerWindow * 4, windowSeconds)) {
|
|
1652
|
+
return c.json(rpcError(null, -32e3, "Too many requests. Try again shortly."), 429);
|
|
1653
|
+
}
|
|
1342
1654
|
const needsTools = requests.some((request) => isRecord3(request) && request["id"] !== void 0 && (request["method"] === "tools/list" || request["method"] === "tools/call"));
|
|
1343
1655
|
const collected = needsTools ? await collectTools({
|
|
1344
1656
|
config: { ...tenant.config, apis: tenant.config.apis.filter((api) => api.exposeAsMcp) },
|
|
@@ -1511,6 +1823,15 @@ function buildSystemPrompt(config, session, availableTools) {
|
|
|
1511
1823
|
"- If you need to think or plan before answering, put every word of that thinking inside",
|
|
1512
1824
|
" <think>\u2026</think> tags. Everything OUTSIDE those tags is shown to the user verbatim, so nothing",
|
|
1513
1825
|
" but the finished answer belongs there; the <think> block is removed and never reaches the user.",
|
|
1826
|
+
"- Before calling a tool, every input it needs must come from the user or from an earlier tool result.",
|
|
1827
|
+
" Never invent a topic, name, identifier, date or other value the user has not given \u2014 ask one",
|
|
1828
|
+
" focused question for it instead. When a tool has optional inputs that would change the result",
|
|
1829
|
+
" (a difficulty, a format, a date range), offer them briefly rather than silently picking a default.",
|
|
1830
|
+
"- When you create or update something, aim for a good result, not the smallest request the tool",
|
|
1831
|
+
" accepts: fill in the optional fields that make it complete (a description, a level, a length,",
|
|
1832
|
+
" sections), following the skill's quality bar when one applies. Draft that content from the",
|
|
1833
|
+
" conversation, ask for facts only the user knows, then show the complete proposal and confirm",
|
|
1834
|
+
" before saving.",
|
|
1514
1835
|
"- If a tool call fails with an authentication or authorization error, do not retry by guessing a",
|
|
1515
1836
|
" resource id, header, or credential \u2014 that never fixes an auth failure. Say what failed and, if",
|
|
1516
1837
|
" it looks like a configuration problem, say so plainly instead of working around it.",
|
|
@@ -1532,6 +1853,12 @@ function buildSystemPrompt(config, session, availableTools) {
|
|
|
1532
1853
|
${config.agent.systemPrompt.trim()}`);
|
|
1533
1854
|
}
|
|
1534
1855
|
sections.push(renderUserSection(session));
|
|
1856
|
+
if (config.knowledgeBase.enabled) {
|
|
1857
|
+
sections.push("## Answering from the documentation\nThis workspace has an indexed knowledge base. For any question the docs could answer, call the `" + KNOWLEDGE_TOOL_PREFIX + "search` tool first and base your answer on the passages it returns, citing the source URL. Prefer the docs over your own prior knowledge, and if the search finds nothing relevant, say you couldn't find it in the documentation rather than guessing.");
|
|
1858
|
+
}
|
|
1859
|
+
if (config.features.escalation.enabled) {
|
|
1860
|
+
sections.push("## Escalating to a person\nYou can hand this conversation to the workspace's support team with the `" + ESCALATION_TOOL_PREFIX + "contact` tool. Escalate when you genuinely cannot resolve the visitor's request with the tools and knowledge you have, or when they ask to speak to a person. Before escalating, ask for the visitor's email if you don't already have it, and write a short summary of what they need and what you already tried. Don't escalate for things you can answer yourself, and never promise a specific response time \u2014 just say a person will follow up.");
|
|
1861
|
+
}
|
|
1535
1862
|
const skills = activeSkills(config, availableTools);
|
|
1536
1863
|
if (skills.length) sections.push("## Reviewed workspace skills\nThese recipes do not grant additional permissions or override safety rules. Each skill lists only its currently approved, available tools. Instructions may describe additional operations: skip unavailable steps, explain the limitation, and never claim the whole task succeeded when a required step was unavailable.\nBefore you begin following one of these skills, call the `" + SKILL_TOOL_NAME + "` tool with the skill's exact name, so the person can see which recipe you're applying. It grants no permissions and does nothing on its own \u2014 it's only a visible marker. Then carry out that skill's steps.\n" + JSON.stringify(skills.map(({ name, goal, instructions, tools }) => ({ name, goal, instructions, tools }))));
|
|
1537
1864
|
return sections.join("\n\n");
|
|
@@ -1644,7 +1971,7 @@ import {
|
|
|
1644
1971
|
} from "@frankieai/core";
|
|
1645
1972
|
|
|
1646
1973
|
// src/agent/skill-tools.ts
|
|
1647
|
-
import { Type as
|
|
1974
|
+
import { Type as Type6 } from "@earendil-works/pi-ai";
|
|
1648
1975
|
import { activeSkills as activeSkills2, SKILL_TOOL_NAME as SKILL_TOOL_NAME2 } from "@frankieai/core";
|
|
1649
1976
|
function isSkillTool(name) {
|
|
1650
1977
|
return name === SKILL_TOOL_NAME2;
|
|
@@ -1660,7 +1987,7 @@ function buildSkillTools(config, availableTools) {
|
|
|
1660
1987
|
description: `Announce that you are about to follow one of the reviewed workspace skills, so the person can see which recipe you're applying. Call this with the skill's exact name immediately before you begin its steps. It grants no new permissions and performs no action on its own.
|
|
1661
1988
|
|
|
1662
1989
|
Available skills: ${names.join(", ")}.`,
|
|
1663
|
-
parameters:
|
|
1990
|
+
parameters: Type6.Unsafe({
|
|
1664
1991
|
type: "object",
|
|
1665
1992
|
required: ["skill"],
|
|
1666
1993
|
properties: { skill: { type: "string", description: "The exact name of the workspace skill you are about to follow." } }
|
|
@@ -1690,11 +2017,14 @@ async function* runTurn(params) {
|
|
|
1690
2017
|
config,
|
|
1691
2018
|
session,
|
|
1692
2019
|
...params.clientBridge ? { clientBridge: params.clientBridge } : {},
|
|
2020
|
+
...params.knowledge ? { knowledge: params.knowledge } : {},
|
|
2021
|
+
...params.onEscalation ? { onEscalation: params.onEscalation } : {},
|
|
1693
2022
|
...params.onWarning ? { onWarning: params.onWarning } : {}
|
|
1694
2023
|
});
|
|
1695
2024
|
const availableTools = toolset.tools.map((tool) => tool.name);
|
|
1696
2025
|
const skillTools = buildSkillTools(config, availableTools);
|
|
1697
2026
|
const agentTools = [...toolset.tools, ...skillTools];
|
|
2027
|
+
const labels = new Map(agentTools.filter((tool) => tool.name.startsWith("frankie__")).map((tool) => [tool.name, tool.label]));
|
|
1698
2028
|
const queue = new EventQueue();
|
|
1699
2029
|
let turnCount = 0;
|
|
1700
2030
|
let lastAssistant;
|
|
@@ -1730,7 +2060,7 @@ async function* runTurn(params) {
|
|
|
1730
2060
|
// tools are exempt: they're gated by the workspaceAdmin flag, not the
|
|
1731
2061
|
// allow/deny policy, so the policy would otherwise block them here.
|
|
1732
2062
|
beforeToolCall: async ({ toolCall }) => {
|
|
1733
|
-
if (isPlatformTool(toolCall.name) || isSkillTool(toolCall.name)) return void 0;
|
|
2063
|
+
if (isPlatformTool(toolCall.name) || isSkillTool(toolCall.name) || isKnowledgeTool(toolCall.name) || isEscalationTool(toolCall.name)) return void 0;
|
|
1734
2064
|
const decision = evaluateToolPolicy3(toolCall.name, config.agent.tools);
|
|
1735
2065
|
if (decision.allowed) return void 0;
|
|
1736
2066
|
queue.push({
|
|
@@ -1745,7 +2075,7 @@ async function* runTurn(params) {
|
|
|
1745
2075
|
});
|
|
1746
2076
|
const stripper = new ReasoningStripper();
|
|
1747
2077
|
const unsubscribe = agent.subscribe((event) => {
|
|
1748
|
-
for (const mapped of mapEvent(event, config, stripper)) queue.push(mapped);
|
|
2078
|
+
for (const mapped of mapEvent(event, config, stripper, labels)) queue.push(mapped);
|
|
1749
2079
|
if (event.type === "message_end" && isAssistant(event.message)) {
|
|
1750
2080
|
lastAssistant = event.message;
|
|
1751
2081
|
usage.inputTokens += event.message.usage.input;
|
|
@@ -1791,7 +2121,7 @@ async function* runTurn(params) {
|
|
|
1791
2121
|
await toolset.close();
|
|
1792
2122
|
}
|
|
1793
2123
|
}
|
|
1794
|
-
function* mapEvent(event, config, stripper) {
|
|
2124
|
+
function* mapEvent(event, config, stripper, labels) {
|
|
1795
2125
|
switch (event.type) {
|
|
1796
2126
|
case "message_start":
|
|
1797
2127
|
if (isAssistant(event.message)) {
|
|
@@ -1821,7 +2151,7 @@ function* mapEvent(event, config, stripper) {
|
|
|
1821
2151
|
type: "tool_start",
|
|
1822
2152
|
toolUseId: event.toolCallId,
|
|
1823
2153
|
name: event.toolName,
|
|
1824
|
-
label: describeTool3(event.toolName, event.args),
|
|
2154
|
+
label: labels.get(event.toolName) ?? describeTool3(event.toolName, event.args),
|
|
1825
2155
|
input: config.agent.showToolActivity ? event.args : void 0
|
|
1826
2156
|
};
|
|
1827
2157
|
return;
|
|
@@ -1841,7 +2171,7 @@ function stopReasonFor(signal, turnCount, config, last) {
|
|
|
1841
2171
|
if (signal.aborted) return "aborted";
|
|
1842
2172
|
if (last?.stopReason === "error") return "error";
|
|
1843
2173
|
if (last?.stopReason === "length") return "max_tokens";
|
|
1844
|
-
if (turnCount >= config.agent.maxTurns) return "max_turns";
|
|
2174
|
+
if (turnCount >= config.agent.maxTurns && last?.stopReason === "toolUse") return "max_turns";
|
|
1845
2175
|
return "end_turn";
|
|
1846
2176
|
}
|
|
1847
2177
|
function isAssistant(message) {
|
|
@@ -1998,8 +2328,31 @@ var EventQueue = class {
|
|
|
1998
2328
|
// src/app.ts
|
|
1999
2329
|
import { randomUUID as randomUUID4 } from "crypto";
|
|
2000
2330
|
import { Hono as Hono3 } from "hono";
|
|
2331
|
+
import { bodyLimit as bodyLimit2 } from "hono/body-limit";
|
|
2001
2332
|
import { suggestedPromptsFromSkills } from "@frankieai/core";
|
|
2002
2333
|
|
|
2334
|
+
// src/knowledge.ts
|
|
2335
|
+
function rankChunks(chunks, queryVector, k) {
|
|
2336
|
+
return chunks.map((chunk) => ({ url: chunk.url, title: chunk.title, text: chunk.text, score: cosineSimilarity(chunk.embedding, queryVector) })).sort((a, b) => b.score - a.score).slice(0, Math.max(0, k));
|
|
2337
|
+
}
|
|
2338
|
+
var MemoryKnowledgeStore = class {
|
|
2339
|
+
byTenant = /* @__PURE__ */ new Map();
|
|
2340
|
+
async replace(tenantId, chunks, meta) {
|
|
2341
|
+
this.byTenant.set(tenantId, { chunks, meta });
|
|
2342
|
+
}
|
|
2343
|
+
async search(tenantId, queryVector, k) {
|
|
2344
|
+
return rankChunks(this.byTenant.get(tenantId)?.chunks ?? [], queryVector, k);
|
|
2345
|
+
}
|
|
2346
|
+
async stats(tenantId) {
|
|
2347
|
+
const entry = this.byTenant.get(tenantId);
|
|
2348
|
+
if (!entry) return { chunks: 0, pages: 0, sources: [] };
|
|
2349
|
+
return { chunks: entry.chunks.length, pages: entry.meta.pages, sources: entry.meta.sources, ingestedAt: entry.meta.ingestedAt };
|
|
2350
|
+
}
|
|
2351
|
+
async clear(tenantId) {
|
|
2352
|
+
this.byTenant.delete(tenantId);
|
|
2353
|
+
}
|
|
2354
|
+
};
|
|
2355
|
+
|
|
2003
2356
|
// src/guide.ts
|
|
2004
2357
|
import { Hono as Hono2 } from "hono";
|
|
2005
2358
|
var STEPS = [
|
|
@@ -2184,12 +2537,13 @@ function createFrankieApp(options) {
|
|
|
2184
2537
|
}
|
|
2185
2538
|
const registry = options.resolved ? TenantRegistry.single(options.resolved) : options.tenants instanceof TenantRegistry ? options.tenants : new TenantRegistry(options.tenants);
|
|
2186
2539
|
const sessions = options.sessions ?? new MemorySessionStore();
|
|
2540
|
+
const knowledgeStore = options.knowledge ?? new MemoryKnowledgeStore();
|
|
2187
2541
|
const limiter = options.rateLimiter ?? new MemoryRateLimiter();
|
|
2188
2542
|
const clientTools = new ClientToolChannel();
|
|
2189
2543
|
const log = options.logger ?? ((event) => console.log(JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...event })));
|
|
2190
2544
|
const app = new Hono3();
|
|
2191
2545
|
app.get("/healthz", (c) => c.json({ ok: true }));
|
|
2192
|
-
app.route("/mcp", createMcpHostApp({ tenants: registry, logger: log }));
|
|
2546
|
+
app.route("/mcp", createMcpHostApp({ tenants: registry, logger: log, rateLimiter: limiter }));
|
|
2193
2547
|
app.route("/guide", createGuideApp());
|
|
2194
2548
|
app.use("/v1/*", async (c, next) => {
|
|
2195
2549
|
const origin = c.req.header("origin");
|
|
@@ -2220,6 +2574,14 @@ function createFrankieApp(options) {
|
|
|
2220
2574
|
await next();
|
|
2221
2575
|
return;
|
|
2222
2576
|
});
|
|
2577
|
+
app.use("/v1/*", async (c, next) => {
|
|
2578
|
+
const { attachments } = c.get("tenant").config.features;
|
|
2579
|
+
const maxSize = (attachments.enabled ? Math.ceil(attachments.maxMb * attachments.maxCount * 1024 * 1024 * 1.4) : 0) + 1024 * 1024;
|
|
2580
|
+
return bodyLimit2({
|
|
2581
|
+
maxSize,
|
|
2582
|
+
onError: (ctx) => jsonError(ctx, 413, "payload_too_large", "That message is too large to send.")
|
|
2583
|
+
})(c, next);
|
|
2584
|
+
});
|
|
2223
2585
|
app.get("/v1/config", (c) => {
|
|
2224
2586
|
const { config } = c.get("tenant");
|
|
2225
2587
|
const publicConfig = {
|
|
@@ -2255,13 +2617,16 @@ function createFrankieApp(options) {
|
|
|
2255
2617
|
// otherwise an enabled picker with an empty list renders nothing.
|
|
2256
2618
|
models: publicPickerModels(config)
|
|
2257
2619
|
}
|
|
2258
|
-
}
|
|
2620
|
+
},
|
|
2621
|
+
// Where this agent shows; the generated FrankieAgents mount reads it so
|
|
2622
|
+
// page changes made in the dashboard apply without a new install PR.
|
|
2623
|
+
pages: config.pagePatterns ?? []
|
|
2259
2624
|
};
|
|
2260
2625
|
return c.json(publicConfig);
|
|
2261
2626
|
});
|
|
2262
2627
|
app.post("/v1/session", async (c) => {
|
|
2263
2628
|
const { config, tokens } = c.get("tenant");
|
|
2264
|
-
const ip =
|
|
2629
|
+
const ip = clientIp2(c);
|
|
2265
2630
|
if (!await limiter.take(`session:${ip}`, config.limits.sessionsPerIpPerWindow, config.limits.windowSeconds)) {
|
|
2266
2631
|
return jsonError(c, 429, "rate_limited", "Too many chat sessions from this address. Try again later.");
|
|
2267
2632
|
}
|
|
@@ -2442,12 +2807,30 @@ function createFrankieApp(options) {
|
|
|
2442
2807
|
}
|
|
2443
2808
|
}
|
|
2444
2809
|
}
|
|
2445
|
-
|
|
2810
|
+
let session = await sessions.get(claims.sessionId);
|
|
2446
2811
|
if (!session) {
|
|
2447
2812
|
return jsonError(c, 404, "session_not_found", "Your chat session expired. Reload the page to start again.");
|
|
2448
2813
|
}
|
|
2449
|
-
if (session.
|
|
2450
|
-
return jsonError(c,
|
|
2814
|
+
if (claims.tenantId !== config.tenantId || session.tenantId !== config.tenantId || session.user.id !== claims.userId) {
|
|
2815
|
+
return jsonError(c, 401, "unauthorized", "This chat session belongs to someone else. Reload the page to start again.");
|
|
2816
|
+
}
|
|
2817
|
+
const leaseMs = config.limits.turnTimeoutMs + BUSY_LEASE_GRACE_MS;
|
|
2818
|
+
if (isTurnRunning(session, leaseMs)) {
|
|
2819
|
+
session = await settledSession(sessions, session, leaseMs);
|
|
2820
|
+
if (!session) {
|
|
2821
|
+
return jsonError(c, 404, "session_not_found", "Your chat session expired. Reload the page to start again.");
|
|
2822
|
+
}
|
|
2823
|
+
if (isTurnRunning(session, leaseMs)) {
|
|
2824
|
+
return jsonError(c, 409, "internal", "The assistant is still answering your previous message.");
|
|
2825
|
+
}
|
|
2826
|
+
} else if (session.busy) {
|
|
2827
|
+
log({
|
|
2828
|
+
level: "warn",
|
|
2829
|
+
event: "busy_lock_expired",
|
|
2830
|
+
tenant: config.tenantId,
|
|
2831
|
+
sessionId: session.id,
|
|
2832
|
+
heldMs: Date.now() - (session.busySince ?? session.lastActiveAt)
|
|
2833
|
+
});
|
|
2451
2834
|
}
|
|
2452
2835
|
if (!await limiter.take(
|
|
2453
2836
|
`chat:${session.id}`,
|
|
@@ -2461,6 +2844,7 @@ function createFrankieApp(options) {
|
|
|
2461
2844
|
}
|
|
2462
2845
|
const patch = {
|
|
2463
2846
|
busy: true,
|
|
2847
|
+
busySince: Date.now(),
|
|
2464
2848
|
messageCount: session.messageCount + 1,
|
|
2465
2849
|
...body.context ? { context: { ...session.context, ...sanitizeContext(body.context) } } : {},
|
|
2466
2850
|
...!session.title ? { title: titleFromText(body.message) } : {}
|
|
@@ -2473,6 +2857,23 @@ function createFrankieApp(options) {
|
|
|
2473
2857
|
const modelOverride = body.model && body.model !== config.model.model && config.features.modelPicker.enabled ? resolveModelChoice(models, config, body.model) ?? void 0 : void 0;
|
|
2474
2858
|
let finalMessages;
|
|
2475
2859
|
let turnCostUsd = 0;
|
|
2860
|
+
const escalations = [];
|
|
2861
|
+
const onEscalation = async (event) => {
|
|
2862
|
+
const { tenantId: _t, conversationId: _c, userId: _u, ...record } = event;
|
|
2863
|
+
escalations.push(record);
|
|
2864
|
+
const webhookUrl = config.features.escalation.webhookUrl;
|
|
2865
|
+
if (!webhookUrl) return;
|
|
2866
|
+
try {
|
|
2867
|
+
await connectorFetch(webhookUrl, {
|
|
2868
|
+
method: "POST",
|
|
2869
|
+
headers: { "content-type": "application/json" },
|
|
2870
|
+
body: JSON.stringify({ type: "frankie.escalation", workspace: config.tenantId, ...event }),
|
|
2871
|
+
signal: AbortSignal.timeout(1e4)
|
|
2872
|
+
});
|
|
2873
|
+
} catch (error) {
|
|
2874
|
+
log({ level: "warn", event: "escalation_webhook_failed", sessionId: session.id, error: error instanceof Error ? error.message : String(error) });
|
|
2875
|
+
}
|
|
2876
|
+
};
|
|
2476
2877
|
const clientBridge = clientTools.bridge(session.id, config.limits.turnTimeoutMs);
|
|
2477
2878
|
const events = runTurn({
|
|
2478
2879
|
config,
|
|
@@ -2482,12 +2883,14 @@ function createFrankieApp(options) {
|
|
|
2482
2883
|
model,
|
|
2483
2884
|
models,
|
|
2484
2885
|
clientBridge,
|
|
2886
|
+
...config.knowledgeBase.enabled ? { knowledge: { store: knowledgeStore, embedding: resolveEmbeddingConfig(config, secrets.providerApiKey) } } : {},
|
|
2485
2887
|
...modelOverride ? { modelOverride } : {},
|
|
2486
2888
|
...attachments.length ? { attachments } : {},
|
|
2487
2889
|
signal: abort.signal,
|
|
2488
2890
|
onTranscript: (messages) => {
|
|
2489
2891
|
finalMessages = messages;
|
|
2490
2892
|
},
|
|
2893
|
+
onEscalation,
|
|
2491
2894
|
onWarning: (event, detail) => log({ level: "warn", event, sessionId: session.id, ...detail })
|
|
2492
2895
|
});
|
|
2493
2896
|
const encoder = new TextEncoder();
|
|
@@ -2524,7 +2927,8 @@ function createFrankieApp(options) {
|
|
|
2524
2927
|
await sessions.update(session.id, {
|
|
2525
2928
|
busy: false,
|
|
2526
2929
|
...finalMessages ? { messages: finalMessages } : {},
|
|
2527
|
-
...turnCostUsd ? { costUsd: current.costUsd + turnCostUsd } : {}
|
|
2930
|
+
...turnCostUsd ? { costUsd: current.costUsd + turnCostUsd } : {},
|
|
2931
|
+
...escalations.length ? { escalations: [...current.escalations ?? [], ...escalations] } : {}
|
|
2528
2932
|
});
|
|
2529
2933
|
controller.close();
|
|
2530
2934
|
}
|
|
@@ -2607,10 +3011,8 @@ function setCors(c, origin) {
|
|
|
2607
3011
|
function jsonError(c, status, code, message) {
|
|
2608
3012
|
return c.json({ error: { code, message } }, status);
|
|
2609
3013
|
}
|
|
2610
|
-
function
|
|
2611
|
-
|
|
2612
|
-
if (forwarded) return forwarded.split(",")[0]?.trim() ?? "unknown";
|
|
2613
|
-
return c.req.header("cf-connecting-ip") ?? c.req.header("x-real-ip") ?? "unknown";
|
|
3014
|
+
function clientIp2(c) {
|
|
3015
|
+
return clientIp((name) => c.req.header(name));
|
|
2614
3016
|
}
|
|
2615
3017
|
function sanitizeContext(context) {
|
|
2616
3018
|
if (!context) return {};
|
|
@@ -2629,6 +3031,18 @@ function sanitizeContext(context) {
|
|
|
2629
3031
|
function validConversationId(value) {
|
|
2630
3032
|
return typeof value === "string" && /^[A-Za-z0-9_-]{8,128}$/.test(value);
|
|
2631
3033
|
}
|
|
3034
|
+
var BUSY_LEASE_GRACE_MS = 3e4;
|
|
3035
|
+
var BUSY_SETTLE_WAIT_MS = 1500;
|
|
3036
|
+
var BUSY_SETTLE_POLL_MS = 250;
|
|
3037
|
+
async function settledSession(sessions, session, leaseMs) {
|
|
3038
|
+
const deadline = Date.now() + BUSY_SETTLE_WAIT_MS;
|
|
3039
|
+
let latest = session;
|
|
3040
|
+
while (latest && isTurnRunning(latest, leaseMs) && Date.now() < deadline) {
|
|
3041
|
+
await new Promise((resolve2) => setTimeout(resolve2, BUSY_SETTLE_POLL_MS));
|
|
3042
|
+
latest = await sessions.get(session.id);
|
|
3043
|
+
}
|
|
3044
|
+
return latest;
|
|
3045
|
+
}
|
|
2632
3046
|
function titleFromText(value) {
|
|
2633
3047
|
const title = value.trim().replace(/\s+/g, " ");
|
|
2634
3048
|
return title.slice(0, 42) + (title.length > 42 ? "\u2026" : "");
|
|
@@ -2677,8 +3091,9 @@ function titleFromClientMessages(messages) {
|
|
|
2677
3091
|
import { mkdir, readFile as readFile2, readdir, rename, rm, writeFile } from "fs/promises";
|
|
2678
3092
|
import { randomUUID as randomUUID5 } from "crypto";
|
|
2679
3093
|
import { join, resolve } from "path";
|
|
2680
|
-
import { parseFrankieConfig } from "@frankieai/core";
|
|
3094
|
+
import { parseFrankieConfig, resolvePersonaConfig as resolvePersonaConfig2 } from "@frankieai/core";
|
|
2681
3095
|
var MEMBERS_FILE = "__members.json";
|
|
3096
|
+
var SUBSCRIPTIONS_FILE = "__subscriptions.json";
|
|
2682
3097
|
function publicVercelLink(link) {
|
|
2683
3098
|
const { accessToken: _token, ...rest } = link;
|
|
2684
3099
|
return rest;
|
|
@@ -2731,10 +3146,15 @@ function toPublicTenant(record) {
|
|
|
2731
3146
|
}))
|
|
2732
3147
|
],
|
|
2733
3148
|
locked: record.locked ?? false,
|
|
3149
|
+
apiAutoRefresh: record.apiAutoRefresh ?? false,
|
|
3150
|
+
...record.suggestedTheme ? { suggestedTheme: record.suggestedTheme } : {},
|
|
2734
3151
|
...record.repositoryInstall ? { repositoryInstall: record.repositoryInstall } : {},
|
|
3152
|
+
...record.personaInstalls ? { personaInstalls: record.personaInstalls } : {},
|
|
2735
3153
|
...record.vercel ? { vercel: publicVercelLink(record.vercel) } : {},
|
|
2736
3154
|
theme: config.theme,
|
|
2737
3155
|
features: config.features,
|
|
3156
|
+
knowledgeBase: config.knowledgeBase,
|
|
3157
|
+
personas: config.personas,
|
|
2738
3158
|
agent: {
|
|
2739
3159
|
greeting: config.agent.greeting,
|
|
2740
3160
|
suggestedPrompts: config.agent.suggestedPrompts,
|
|
@@ -2787,6 +3207,8 @@ var FileTenantStore = class _FileTenantStore {
|
|
|
2787
3207
|
byId = /* @__PURE__ */ new Map();
|
|
2788
3208
|
members = /* @__PURE__ */ new Map();
|
|
2789
3209
|
// by `${org}\n${login}`
|
|
3210
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
3211
|
+
// by org (owner id)
|
|
2790
3212
|
loaded = false;
|
|
2791
3213
|
static memberKey(org, login) {
|
|
2792
3214
|
return `${org}
|
|
@@ -2803,7 +3225,7 @@ ${login.toLowerCase()}`;
|
|
|
2803
3225
|
const files = await readdir(this.dir).catch(() => []);
|
|
2804
3226
|
for (const file of files) {
|
|
2805
3227
|
if (!file.endsWith(".json")) continue;
|
|
2806
|
-
if (file === MEMBERS_FILE) continue;
|
|
3228
|
+
if (file === MEMBERS_FILE || file === SUBSCRIPTIONS_FILE) continue;
|
|
2807
3229
|
try {
|
|
2808
3230
|
const record = JSON.parse(await readFile2(join(this.dir, file), "utf8"));
|
|
2809
3231
|
record.config = parseFrankieConfig(record.config);
|
|
@@ -2818,6 +3240,11 @@ ${login.toLowerCase()}`;
|
|
|
2818
3240
|
}
|
|
2819
3241
|
} catch {
|
|
2820
3242
|
}
|
|
3243
|
+
try {
|
|
3244
|
+
const raw = await readFile2(join(this.dir, SUBSCRIPTIONS_FILE), "utf8");
|
|
3245
|
+
for (const sub of JSON.parse(raw)) this.subscriptions.set(sub.org, sub);
|
|
3246
|
+
} catch {
|
|
3247
|
+
}
|
|
2821
3248
|
this.loaded = true;
|
|
2822
3249
|
}
|
|
2823
3250
|
async persistMembers() {
|
|
@@ -2842,18 +3269,38 @@ ${login.toLowerCase()}`;
|
|
|
2842
3269
|
const lower = login.toLowerCase();
|
|
2843
3270
|
return [...this.members.values()].filter((m) => m.login === lower);
|
|
2844
3271
|
}
|
|
3272
|
+
async persistSubscriptions() {
|
|
3273
|
+
await mkdir(this.dir, { recursive: true });
|
|
3274
|
+
await writeFile(join(this.dir, SUBSCRIPTIONS_FILE), JSON.stringify([...this.subscriptions.values()], null, 2), "utf8");
|
|
3275
|
+
}
|
|
3276
|
+
async getSubscription(org) {
|
|
3277
|
+
await this.load();
|
|
3278
|
+
return this.subscriptions.get(org) ?? null;
|
|
3279
|
+
}
|
|
3280
|
+
async putSubscription(subscription) {
|
|
3281
|
+
await this.load();
|
|
3282
|
+
this.subscriptions.set(subscription.org, subscription);
|
|
3283
|
+
await this.persistSubscriptions();
|
|
3284
|
+
}
|
|
2845
3285
|
remember(record) {
|
|
3286
|
+
const prior = this.byId.get(record.id);
|
|
3287
|
+
if (prior) {
|
|
3288
|
+
for (const [key, value] of this.index) if (value === prior) this.index.delete(key);
|
|
3289
|
+
}
|
|
2846
3290
|
this.byId.set(record.id, record);
|
|
2847
3291
|
this.index.set(record.config.auth.publishableKey, record);
|
|
3292
|
+
for (const persona of record.config.personas) this.index.set(persona.publishableKey, record);
|
|
2848
3293
|
}
|
|
2849
3294
|
async byPublishableKey(key) {
|
|
2850
3295
|
await this.load();
|
|
2851
3296
|
const record = this.index.get(key);
|
|
2852
3297
|
if (!record) return null;
|
|
2853
|
-
const
|
|
3298
|
+
const config = resolvePersonaConfig2(record.config, key);
|
|
3299
|
+
if (!config) return null;
|
|
3300
|
+
const providerApiKey = this.env[config.model.apiKeyEnv] ?? this.env[this.providerKeyEnv];
|
|
2854
3301
|
if (!providerApiKey) return null;
|
|
2855
3302
|
return {
|
|
2856
|
-
config
|
|
3303
|
+
config,
|
|
2857
3304
|
secrets: {
|
|
2858
3305
|
...record.jwtSecret ? { jwtSecret: record.jwtSecret } : {},
|
|
2859
3306
|
sessionSecret: record.sessionSecret,
|
|
@@ -2877,29 +3324,40 @@ ${login.toLowerCase()}`;
|
|
|
2877
3324
|
throw new Error(`Publishable key already belongs to tenant "${existing.id}".`);
|
|
2878
3325
|
}
|
|
2879
3326
|
this.remember(record);
|
|
2880
|
-
await
|
|
2881
|
-
await writeFile(join(this.dir, `${record.id}.json`), JSON.stringify(record, null, 2), "utf8");
|
|
3327
|
+
await this.write(record);
|
|
2882
3328
|
}
|
|
2883
3329
|
async compareAndSwap(previous, next) {
|
|
2884
3330
|
await this.load();
|
|
2885
3331
|
if (this.byId.get(previous.id) !== previous) return false;
|
|
2886
3332
|
this.remember(next);
|
|
2887
|
-
await
|
|
2888
|
-
await writeFile(join(this.dir, `${next.id}.json`), JSON.stringify(next, null, 2), "utf8");
|
|
3333
|
+
await this.write(next);
|
|
2889
3334
|
return true;
|
|
2890
3335
|
}
|
|
3336
|
+
/**
|
|
3337
|
+
* A record holds the workspace's signing and session secrets and source
|
|
3338
|
+
* credentials: owner-only permissions, and written whole (temp file, then
|
|
3339
|
+
* rename) so a crash never leaves half a record behind.
|
|
3340
|
+
*/
|
|
3341
|
+
async write(record) {
|
|
3342
|
+
await mkdir(this.dir, { recursive: true, mode: 448 });
|
|
3343
|
+
const path = join(this.dir, `${record.id}.json`);
|
|
3344
|
+
const temporary = `${path}.${process.pid}.${randomUUID5()}.tmp`;
|
|
3345
|
+
await writeFile(temporary, JSON.stringify(record, null, 2), { encoding: "utf8", mode: 384 });
|
|
3346
|
+
await rename(temporary, path);
|
|
3347
|
+
}
|
|
2891
3348
|
async delete(id) {
|
|
2892
3349
|
await this.load();
|
|
2893
3350
|
const record = this.byId.get(id);
|
|
2894
3351
|
if (!record) return;
|
|
2895
3352
|
this.byId.delete(id);
|
|
2896
3353
|
this.index.delete(record.config.auth.publishableKey);
|
|
3354
|
+
for (const persona of record.config.personas) this.index.delete(persona.publishableKey);
|
|
2897
3355
|
await rm(join(this.dir, `${id}.json`), { force: true });
|
|
2898
3356
|
}
|
|
2899
3357
|
};
|
|
2900
3358
|
|
|
2901
3359
|
// src/control/postgres-store.ts
|
|
2902
|
-
import { parseFrankieConfig as parseFrankieConfig2 } from "@frankieai/core";
|
|
3360
|
+
import { parseFrankieConfig as parseFrankieConfig2, resolvePersonaConfig as resolvePersonaConfig3 } from "@frankieai/core";
|
|
2903
3361
|
var PostgresTenantStore = class {
|
|
2904
3362
|
snapshots = /* @__PURE__ */ new WeakMap();
|
|
2905
3363
|
db;
|
|
@@ -2946,6 +3404,28 @@ var PostgresTenantStore = class {
|
|
|
2946
3404
|
)`
|
|
2947
3405
|
);
|
|
2948
3406
|
await this.db.query(`CREATE INDEX IF NOT EXISTS frankie_org_members_login ON frankie_org_members (login)`);
|
|
3407
|
+
await this.db.query(
|
|
3408
|
+
`CREATE TABLE IF NOT EXISTS frankie_subscriptions (
|
|
3409
|
+
org text PRIMARY KEY,
|
|
3410
|
+
plan text NOT NULL,
|
|
3411
|
+
status text NOT NULL,
|
|
3412
|
+
stripe_customer_id text,
|
|
3413
|
+
stripe_subscription_id text,
|
|
3414
|
+
current_period_end text,
|
|
3415
|
+
updated_at text NOT NULL
|
|
3416
|
+
)`
|
|
3417
|
+
);
|
|
3418
|
+
}
|
|
3419
|
+
subscriptionRow(row) {
|
|
3420
|
+
return {
|
|
3421
|
+
org: String(row.org),
|
|
3422
|
+
plan: row.plan === "pro" ? "pro" : "free",
|
|
3423
|
+
status: ["active", "trialing", "past_due", "canceled", "incomplete"].includes(String(row.status)) ? String(row.status) : "active",
|
|
3424
|
+
...row.stripe_customer_id ? { stripeCustomerId: String(row.stripe_customer_id) } : {},
|
|
3425
|
+
...row.stripe_subscription_id ? { stripeSubscriptionId: String(row.stripe_subscription_id) } : {},
|
|
3426
|
+
...row.current_period_end ? { currentPeriodEnd: String(row.current_period_end) } : {},
|
|
3427
|
+
updatedAt: String(row.updated_at)
|
|
3428
|
+
};
|
|
2949
3429
|
}
|
|
2950
3430
|
memberRow(row) {
|
|
2951
3431
|
return {
|
|
@@ -3007,6 +3487,23 @@ var PostgresTenantStore = class {
|
|
|
3007
3487
|
await this.ensureReady();
|
|
3008
3488
|
await this.db.query(`DELETE FROM frankie_org_members WHERE org = $1 AND login = $2`, [org, login.toLowerCase()]);
|
|
3009
3489
|
}
|
|
3490
|
+
async getSubscription(org) {
|
|
3491
|
+
await this.ensureReady();
|
|
3492
|
+
const { rows } = await this.db.query(`SELECT * FROM frankie_subscriptions WHERE org = $1`, [org]);
|
|
3493
|
+
return rows[0] ? this.subscriptionRow(rows[0]) : null;
|
|
3494
|
+
}
|
|
3495
|
+
async putSubscription(subscription) {
|
|
3496
|
+
await this.ensureReady();
|
|
3497
|
+
await this.db.query(
|
|
3498
|
+
`INSERT INTO frankie_subscriptions (org, plan, status, stripe_customer_id, stripe_subscription_id, current_period_end, updated_at)
|
|
3499
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
3500
|
+
ON CONFLICT (org) DO UPDATE SET
|
|
3501
|
+
plan = EXCLUDED.plan, status = EXCLUDED.status,
|
|
3502
|
+
stripe_customer_id = EXCLUDED.stripe_customer_id, stripe_subscription_id = EXCLUDED.stripe_subscription_id,
|
|
3503
|
+
current_period_end = EXCLUDED.current_period_end, updated_at = EXCLUDED.updated_at`,
|
|
3504
|
+
[subscription.org, subscription.plan, subscription.status, subscription.stripeCustomerId ?? null, subscription.stripeSubscriptionId ?? null, subscription.currentPeriodEnd ?? null, subscription.updatedAt]
|
|
3505
|
+
);
|
|
3506
|
+
}
|
|
3010
3507
|
async membershipsForLogin(login) {
|
|
3011
3508
|
await this.ensureReady();
|
|
3012
3509
|
const { rows } = await this.db.query(`SELECT * FROM frankie_org_members WHERE login = $1`, [login.toLowerCase()]);
|
|
@@ -3020,19 +3517,27 @@ var PostgresTenantStore = class {
|
|
|
3020
3517
|
}
|
|
3021
3518
|
async byPublishableKey(key) {
|
|
3022
3519
|
await this.ensureReady();
|
|
3023
|
-
|
|
3024
|
-
if (rows.length === 0
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3520
|
+
let records = await this.db.query(`SELECT record FROM frankie_tenants WHERE publishable_key = $1`, [key]);
|
|
3521
|
+
if (records.rows.length === 0) {
|
|
3522
|
+
if (!/^pk_[A-Za-z0-9_-]{1,120}$/.test(key)) return null;
|
|
3523
|
+
records = await this.db.query(`SELECT record FROM frankie_tenants WHERE record LIKE $1`, [`%"publishableKey":${JSON.stringify(key)}%`]);
|
|
3524
|
+
}
|
|
3525
|
+
for (const row of records.rows) {
|
|
3526
|
+
const record = this.parse(row);
|
|
3527
|
+
const config = resolvePersonaConfig3(record.config, key);
|
|
3528
|
+
if (!config) continue;
|
|
3529
|
+
const providerApiKey = this.env[config.model.apiKeyEnv] ?? this.env[this.providerKeyEnv];
|
|
3530
|
+
if (!providerApiKey) return null;
|
|
3531
|
+
return {
|
|
3532
|
+
config,
|
|
3533
|
+
secrets: {
|
|
3534
|
+
...record.jwtSecret ? { jwtSecret: record.jwtSecret } : {},
|
|
3535
|
+
sessionSecret: record.sessionSecret,
|
|
3536
|
+
providerApiKey
|
|
3537
|
+
}
|
|
3538
|
+
};
|
|
3539
|
+
}
|
|
3540
|
+
return null;
|
|
3036
3541
|
}
|
|
3037
3542
|
async list(ownerId) {
|
|
3038
3543
|
await this.ensureReady();
|
|
@@ -3171,6 +3676,53 @@ var PostgresSessionStore = class {
|
|
|
3171
3676
|
}
|
|
3172
3677
|
};
|
|
3173
3678
|
|
|
3679
|
+
// src/postgres-knowledge.ts
|
|
3680
|
+
var PostgresKnowledgeStore = class {
|
|
3681
|
+
db;
|
|
3682
|
+
ready;
|
|
3683
|
+
constructor(options) {
|
|
3684
|
+
this.db = options.db;
|
|
3685
|
+
}
|
|
3686
|
+
ensureReady() {
|
|
3687
|
+
return this.ready ??= this.init();
|
|
3688
|
+
}
|
|
3689
|
+
async init() {
|
|
3690
|
+
await this.db.query(
|
|
3691
|
+
`CREATE TABLE IF NOT EXISTS frankie_kb (
|
|
3692
|
+
tenant_id text PRIMARY KEY,
|
|
3693
|
+
data text NOT NULL
|
|
3694
|
+
)`
|
|
3695
|
+
);
|
|
3696
|
+
}
|
|
3697
|
+
async load(tenantId) {
|
|
3698
|
+
await this.ensureReady();
|
|
3699
|
+
const { rows } = await this.db.query(`SELECT data FROM frankie_kb WHERE tenant_id = $1`, [tenantId]);
|
|
3700
|
+
if (rows.length === 0 || !rows[0]) return void 0;
|
|
3701
|
+
return JSON.parse(String(rows[0].data));
|
|
3702
|
+
}
|
|
3703
|
+
async replace(tenantId, chunks, meta) {
|
|
3704
|
+
await this.ensureReady();
|
|
3705
|
+
const data = JSON.stringify({ chunks, meta });
|
|
3706
|
+
await this.db.query(
|
|
3707
|
+
`INSERT INTO frankie_kb (tenant_id, data) VALUES ($1, $2)
|
|
3708
|
+
ON CONFLICT (tenant_id) DO UPDATE SET data = EXCLUDED.data`,
|
|
3709
|
+
[tenantId, data]
|
|
3710
|
+
);
|
|
3711
|
+
}
|
|
3712
|
+
async search(tenantId, queryVector, k) {
|
|
3713
|
+
return rankChunks((await this.load(tenantId))?.chunks ?? [], queryVector, k);
|
|
3714
|
+
}
|
|
3715
|
+
async stats(tenantId) {
|
|
3716
|
+
const stored = await this.load(tenantId);
|
|
3717
|
+
if (!stored) return { chunks: 0, pages: 0, sources: [] };
|
|
3718
|
+
return { chunks: stored.chunks.length, pages: stored.meta.pages, sources: stored.meta.sources, ingestedAt: stored.meta.ingestedAt };
|
|
3719
|
+
}
|
|
3720
|
+
async clear(tenantId) {
|
|
3721
|
+
await this.ensureReady();
|
|
3722
|
+
await this.db.query(`DELETE FROM frankie_kb WHERE tenant_id = $1`, [tenantId]);
|
|
3723
|
+
}
|
|
3724
|
+
};
|
|
3725
|
+
|
|
3174
3726
|
// src/control/resolve-store.ts
|
|
3175
3727
|
async function resolveStores(env = process.env) {
|
|
3176
3728
|
const url = env["DATABASE_URL"];
|
|
@@ -3183,10 +3735,11 @@ async function resolveStores(env = process.env) {
|
|
|
3183
3735
|
});
|
|
3184
3736
|
const tenants = new PostgresTenantStore({ db: pool, env });
|
|
3185
3737
|
const sessions = new PostgresSessionStore({ db: pool });
|
|
3186
|
-
|
|
3738
|
+
const knowledge = new PostgresKnowledgeStore({ db: pool });
|
|
3739
|
+
return { tenants, sessions, knowledge, describe: "postgres" };
|
|
3187
3740
|
}
|
|
3188
3741
|
const dir = env["FRANKIE_TENANTS_DIR"] ?? "./.frankie/tenants";
|
|
3189
|
-
return { tenants: new FileTenantStore({ dir, env }), sessions: new MemorySessionStore(), describe: `files:${dir}` };
|
|
3742
|
+
return { tenants: new FileTenantStore({ dir, env }), sessions: new MemorySessionStore(), knowledge: new MemoryKnowledgeStore(), describe: `files:${dir}` };
|
|
3190
3743
|
}
|
|
3191
3744
|
async function resolveTenantStore(env = process.env) {
|
|
3192
3745
|
const url = env["DATABASE_URL"];
|
|
@@ -3218,6 +3771,8 @@ export {
|
|
|
3218
3771
|
MemoryRateLimiter,
|
|
3219
3772
|
MemorySessionStore,
|
|
3220
3773
|
newSessionId,
|
|
3774
|
+
resolveEmbeddingConfig,
|
|
3775
|
+
embedTexts,
|
|
3221
3776
|
TenantRegistry,
|
|
3222
3777
|
SingleTenantStore,
|
|
3223
3778
|
MemoryTenantStore,
|
|
@@ -3234,6 +3789,7 @@ export {
|
|
|
3234
3789
|
securitySchemes,
|
|
3235
3790
|
applySecurity,
|
|
3236
3791
|
collectParameters,
|
|
3792
|
+
describeSchemaInputs,
|
|
3237
3793
|
buildUrl,
|
|
3238
3794
|
slug,
|
|
3239
3795
|
isRecord,
|
|
@@ -3251,4 +3807,4 @@ export {
|
|
|
3251
3807
|
resolveStores,
|
|
3252
3808
|
resolveTenantStore
|
|
3253
3809
|
};
|
|
3254
|
-
//# sourceMappingURL=chunk-
|
|
3810
|
+
//# sourceMappingURL=chunk-WEHJH4FL.js.map
|