@frankieai/server 0.2.4 → 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-YDXEBJON.js → chunk-EYR3BV7U.js} +505 -35
- package/dist/chunk-EYR3BV7U.js.map +1 -0
- package/dist/{chunk-7J5Z5MIK.js → chunk-WEHJH4FL.js} +657 -57
- 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-7J5Z5MIK.js.map +0 -1
- package/dist/chunk-YDXEBJON.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) },
|
|
@@ -1477,7 +1789,7 @@ function msg(error) {
|
|
|
1477
1789
|
}
|
|
1478
1790
|
|
|
1479
1791
|
// src/agent/prompt.ts
|
|
1480
|
-
import { activeSkills } from "@frankieai/core";
|
|
1792
|
+
import { activeSkills, SKILL_TOOL_NAME } from "@frankieai/core";
|
|
1481
1793
|
function buildSystemPrompt(config, session, availableTools) {
|
|
1482
1794
|
const sections = [];
|
|
1483
1795
|
const configurationAssistant = config.features.configurationAssistant;
|
|
@@ -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,8 +1853,14 @@ 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
|
-
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.\n" + JSON.stringify(skills.map(({ name, goal, instructions, tools }) => ({ name, goal, instructions, tools }))));
|
|
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");
|
|
1538
1865
|
}
|
|
1539
1866
|
function isOnboardingAssistant(config) {
|
|
@@ -1639,18 +1966,65 @@ function renderPageContext(context) {
|
|
|
1639
1966
|
import { Agent } from "@earendil-works/pi-agent-core";
|
|
1640
1967
|
import {
|
|
1641
1968
|
describeTool as describeTool3,
|
|
1642
|
-
activeSkills as
|
|
1969
|
+
activeSkills as activeSkills3,
|
|
1643
1970
|
evaluateToolPolicy as evaluateToolPolicy3
|
|
1644
1971
|
} from "@frankieai/core";
|
|
1972
|
+
|
|
1973
|
+
// src/agent/skill-tools.ts
|
|
1974
|
+
import { Type as Type6 } from "@earendil-works/pi-ai";
|
|
1975
|
+
import { activeSkills as activeSkills2, SKILL_TOOL_NAME as SKILL_TOOL_NAME2 } from "@frankieai/core";
|
|
1976
|
+
function isSkillTool(name) {
|
|
1977
|
+
return name === SKILL_TOOL_NAME2;
|
|
1978
|
+
}
|
|
1979
|
+
function buildSkillTools(config, availableTools) {
|
|
1980
|
+
const skills = activeSkills2(config, availableTools);
|
|
1981
|
+
if (!skills.length) return [];
|
|
1982
|
+
const names = skills.map((skill) => skill.name);
|
|
1983
|
+
return [
|
|
1984
|
+
{
|
|
1985
|
+
name: SKILL_TOOL_NAME2,
|
|
1986
|
+
label: "Use skill",
|
|
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.
|
|
1988
|
+
|
|
1989
|
+
Available skills: ${names.join(", ")}.`,
|
|
1990
|
+
parameters: Type6.Unsafe({
|
|
1991
|
+
type: "object",
|
|
1992
|
+
required: ["skill"],
|
|
1993
|
+
properties: { skill: { type: "string", description: "The exact name of the workspace skill you are about to follow." } }
|
|
1994
|
+
}),
|
|
1995
|
+
async execute(_id, args) {
|
|
1996
|
+
const requested = String((typeof args === "object" && args !== null ? args["skill"] : "") ?? "").trim();
|
|
1997
|
+
const match = skills.find((skill) => skill.name.toLowerCase() === requested.toLowerCase());
|
|
1998
|
+
if (!match) {
|
|
1999
|
+
return {
|
|
2000
|
+
content: [{ type: "text", text: `No active skill is named "${requested}". The skills available this turn are: ${names.join(", ")}.` }],
|
|
2001
|
+
details: null
|
|
2002
|
+
};
|
|
2003
|
+
}
|
|
2004
|
+
return {
|
|
2005
|
+
content: [{ type: "text", text: `Now following the "${match.name}" skill. Apply its steps using only its approved tools.` }],
|
|
2006
|
+
details: null
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
];
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// src/agent/runner.ts
|
|
1645
2014
|
async function* runTurn(params) {
|
|
1646
2015
|
const { config, session } = params;
|
|
1647
2016
|
const toolset = await collectTools({
|
|
1648
2017
|
config,
|
|
1649
2018
|
session,
|
|
1650
2019
|
...params.clientBridge ? { clientBridge: params.clientBridge } : {},
|
|
2020
|
+
...params.knowledge ? { knowledge: params.knowledge } : {},
|
|
2021
|
+
...params.onEscalation ? { onEscalation: params.onEscalation } : {},
|
|
1651
2022
|
...params.onWarning ? { onWarning: params.onWarning } : {}
|
|
1652
2023
|
});
|
|
1653
2024
|
const availableTools = toolset.tools.map((tool) => tool.name);
|
|
2025
|
+
const skillTools = buildSkillTools(config, availableTools);
|
|
2026
|
+
const agentTools = [...toolset.tools, ...skillTools];
|
|
2027
|
+
const labels = new Map(agentTools.filter((tool) => tool.name.startsWith("frankie__")).map((tool) => [tool.name, tool.label]));
|
|
1654
2028
|
const queue = new EventQueue();
|
|
1655
2029
|
let turnCount = 0;
|
|
1656
2030
|
let lastAssistant;
|
|
@@ -1662,7 +2036,7 @@ async function* runTurn(params) {
|
|
|
1662
2036
|
systemPrompt: buildSystemPrompt(config, session, availableTools),
|
|
1663
2037
|
model: params.modelOverride ?? params.model,
|
|
1664
2038
|
thinkingLevel: config.model.thinkingLevel,
|
|
1665
|
-
tools:
|
|
2039
|
+
tools: agentTools,
|
|
1666
2040
|
messages: session.messages
|
|
1667
2041
|
},
|
|
1668
2042
|
// Per-request sampling lives here rather than on the agent: the stream
|
|
@@ -1686,7 +2060,7 @@ async function* runTurn(params) {
|
|
|
1686
2060
|
// tools are exempt: they're gated by the workspaceAdmin flag, not the
|
|
1687
2061
|
// allow/deny policy, so the policy would otherwise block them here.
|
|
1688
2062
|
beforeToolCall: async ({ toolCall }) => {
|
|
1689
|
-
if (isPlatformTool(toolCall.name)) return void 0;
|
|
2063
|
+
if (isPlatformTool(toolCall.name) || isSkillTool(toolCall.name) || isKnowledgeTool(toolCall.name) || isEscalationTool(toolCall.name)) return void 0;
|
|
1690
2064
|
const decision = evaluateToolPolicy3(toolCall.name, config.agent.tools);
|
|
1691
2065
|
if (decision.allowed) return void 0;
|
|
1692
2066
|
queue.push({
|
|
@@ -1701,7 +2075,7 @@ async function* runTurn(params) {
|
|
|
1701
2075
|
});
|
|
1702
2076
|
const stripper = new ReasoningStripper();
|
|
1703
2077
|
const unsubscribe = agent.subscribe((event) => {
|
|
1704
|
-
for (const mapped of mapEvent(event, config, stripper)) queue.push(mapped);
|
|
2078
|
+
for (const mapped of mapEvent(event, config, stripper, labels)) queue.push(mapped);
|
|
1705
2079
|
if (event.type === "message_end" && isAssistant(event.message)) {
|
|
1706
2080
|
lastAssistant = event.message;
|
|
1707
2081
|
usage.inputTokens += event.message.usage.input;
|
|
@@ -1728,7 +2102,7 @@ async function* runTurn(params) {
|
|
|
1728
2102
|
});
|
|
1729
2103
|
}).finally(() => queue.close());
|
|
1730
2104
|
try {
|
|
1731
|
-
yield { type: "skills_loaded", skills:
|
|
2105
|
+
yield { type: "skills_loaded", skills: activeSkills3(config, availableTools).map(({ id, name, revision }) => ({ id, name, revision })) };
|
|
1732
2106
|
yield { type: "tools_active", apis: toolset.apis };
|
|
1733
2107
|
for await (const event of queue) yield event;
|
|
1734
2108
|
await run;
|
|
@@ -1747,7 +2121,7 @@ async function* runTurn(params) {
|
|
|
1747
2121
|
await toolset.close();
|
|
1748
2122
|
}
|
|
1749
2123
|
}
|
|
1750
|
-
function* mapEvent(event, config, stripper) {
|
|
2124
|
+
function* mapEvent(event, config, stripper, labels) {
|
|
1751
2125
|
switch (event.type) {
|
|
1752
2126
|
case "message_start":
|
|
1753
2127
|
if (isAssistant(event.message)) {
|
|
@@ -1777,7 +2151,7 @@ function* mapEvent(event, config, stripper) {
|
|
|
1777
2151
|
type: "tool_start",
|
|
1778
2152
|
toolUseId: event.toolCallId,
|
|
1779
2153
|
name: event.toolName,
|
|
1780
|
-
label: describeTool3(event.toolName, event.args),
|
|
2154
|
+
label: labels.get(event.toolName) ?? describeTool3(event.toolName, event.args),
|
|
1781
2155
|
input: config.agent.showToolActivity ? event.args : void 0
|
|
1782
2156
|
};
|
|
1783
2157
|
return;
|
|
@@ -1797,7 +2171,7 @@ function stopReasonFor(signal, turnCount, config, last) {
|
|
|
1797
2171
|
if (signal.aborted) return "aborted";
|
|
1798
2172
|
if (last?.stopReason === "error") return "error";
|
|
1799
2173
|
if (last?.stopReason === "length") return "max_tokens";
|
|
1800
|
-
if (turnCount >= config.agent.maxTurns) return "max_turns";
|
|
2174
|
+
if (turnCount >= config.agent.maxTurns && last?.stopReason === "toolUse") return "max_turns";
|
|
1801
2175
|
return "end_turn";
|
|
1802
2176
|
}
|
|
1803
2177
|
function isAssistant(message) {
|
|
@@ -1954,8 +2328,31 @@ var EventQueue = class {
|
|
|
1954
2328
|
// src/app.ts
|
|
1955
2329
|
import { randomUUID as randomUUID4 } from "crypto";
|
|
1956
2330
|
import { Hono as Hono3 } from "hono";
|
|
2331
|
+
import { bodyLimit as bodyLimit2 } from "hono/body-limit";
|
|
1957
2332
|
import { suggestedPromptsFromSkills } from "@frankieai/core";
|
|
1958
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
|
+
|
|
1959
2356
|
// src/guide.ts
|
|
1960
2357
|
import { Hono as Hono2 } from "hono";
|
|
1961
2358
|
var STEPS = [
|
|
@@ -2140,12 +2537,13 @@ function createFrankieApp(options) {
|
|
|
2140
2537
|
}
|
|
2141
2538
|
const registry = options.resolved ? TenantRegistry.single(options.resolved) : options.tenants instanceof TenantRegistry ? options.tenants : new TenantRegistry(options.tenants);
|
|
2142
2539
|
const sessions = options.sessions ?? new MemorySessionStore();
|
|
2540
|
+
const knowledgeStore = options.knowledge ?? new MemoryKnowledgeStore();
|
|
2143
2541
|
const limiter = options.rateLimiter ?? new MemoryRateLimiter();
|
|
2144
2542
|
const clientTools = new ClientToolChannel();
|
|
2145
2543
|
const log = options.logger ?? ((event) => console.log(JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...event })));
|
|
2146
2544
|
const app = new Hono3();
|
|
2147
2545
|
app.get("/healthz", (c) => c.json({ ok: true }));
|
|
2148
|
-
app.route("/mcp", createMcpHostApp({ tenants: registry, logger: log }));
|
|
2546
|
+
app.route("/mcp", createMcpHostApp({ tenants: registry, logger: log, rateLimiter: limiter }));
|
|
2149
2547
|
app.route("/guide", createGuideApp());
|
|
2150
2548
|
app.use("/v1/*", async (c, next) => {
|
|
2151
2549
|
const origin = c.req.header("origin");
|
|
@@ -2176,6 +2574,14 @@ function createFrankieApp(options) {
|
|
|
2176
2574
|
await next();
|
|
2177
2575
|
return;
|
|
2178
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
|
+
});
|
|
2179
2585
|
app.get("/v1/config", (c) => {
|
|
2180
2586
|
const { config } = c.get("tenant");
|
|
2181
2587
|
const publicConfig = {
|
|
@@ -2211,13 +2617,16 @@ function createFrankieApp(options) {
|
|
|
2211
2617
|
// otherwise an enabled picker with an empty list renders nothing.
|
|
2212
2618
|
models: publicPickerModels(config)
|
|
2213
2619
|
}
|
|
2214
|
-
}
|
|
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 ?? []
|
|
2215
2624
|
};
|
|
2216
2625
|
return c.json(publicConfig);
|
|
2217
2626
|
});
|
|
2218
2627
|
app.post("/v1/session", async (c) => {
|
|
2219
2628
|
const { config, tokens } = c.get("tenant");
|
|
2220
|
-
const ip =
|
|
2629
|
+
const ip = clientIp2(c);
|
|
2221
2630
|
if (!await limiter.take(`session:${ip}`, config.limits.sessionsPerIpPerWindow, config.limits.windowSeconds)) {
|
|
2222
2631
|
return jsonError(c, 429, "rate_limited", "Too many chat sessions from this address. Try again later.");
|
|
2223
2632
|
}
|
|
@@ -2398,12 +2807,30 @@ function createFrankieApp(options) {
|
|
|
2398
2807
|
}
|
|
2399
2808
|
}
|
|
2400
2809
|
}
|
|
2401
|
-
|
|
2810
|
+
let session = await sessions.get(claims.sessionId);
|
|
2402
2811
|
if (!session) {
|
|
2403
2812
|
return jsonError(c, 404, "session_not_found", "Your chat session expired. Reload the page to start again.");
|
|
2404
2813
|
}
|
|
2405
|
-
if (session.
|
|
2406
|
-
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
|
+
});
|
|
2407
2834
|
}
|
|
2408
2835
|
if (!await limiter.take(
|
|
2409
2836
|
`chat:${session.id}`,
|
|
@@ -2417,6 +2844,7 @@ function createFrankieApp(options) {
|
|
|
2417
2844
|
}
|
|
2418
2845
|
const patch = {
|
|
2419
2846
|
busy: true,
|
|
2847
|
+
busySince: Date.now(),
|
|
2420
2848
|
messageCount: session.messageCount + 1,
|
|
2421
2849
|
...body.context ? { context: { ...session.context, ...sanitizeContext(body.context) } } : {},
|
|
2422
2850
|
...!session.title ? { title: titleFromText(body.message) } : {}
|
|
@@ -2429,6 +2857,23 @@ function createFrankieApp(options) {
|
|
|
2429
2857
|
const modelOverride = body.model && body.model !== config.model.model && config.features.modelPicker.enabled ? resolveModelChoice(models, config, body.model) ?? void 0 : void 0;
|
|
2430
2858
|
let finalMessages;
|
|
2431
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
|
+
};
|
|
2432
2877
|
const clientBridge = clientTools.bridge(session.id, config.limits.turnTimeoutMs);
|
|
2433
2878
|
const events = runTurn({
|
|
2434
2879
|
config,
|
|
@@ -2438,12 +2883,14 @@ function createFrankieApp(options) {
|
|
|
2438
2883
|
model,
|
|
2439
2884
|
models,
|
|
2440
2885
|
clientBridge,
|
|
2886
|
+
...config.knowledgeBase.enabled ? { knowledge: { store: knowledgeStore, embedding: resolveEmbeddingConfig(config, secrets.providerApiKey) } } : {},
|
|
2441
2887
|
...modelOverride ? { modelOverride } : {},
|
|
2442
2888
|
...attachments.length ? { attachments } : {},
|
|
2443
2889
|
signal: abort.signal,
|
|
2444
2890
|
onTranscript: (messages) => {
|
|
2445
2891
|
finalMessages = messages;
|
|
2446
2892
|
},
|
|
2893
|
+
onEscalation,
|
|
2447
2894
|
onWarning: (event, detail) => log({ level: "warn", event, sessionId: session.id, ...detail })
|
|
2448
2895
|
});
|
|
2449
2896
|
const encoder = new TextEncoder();
|
|
@@ -2480,7 +2927,8 @@ function createFrankieApp(options) {
|
|
|
2480
2927
|
await sessions.update(session.id, {
|
|
2481
2928
|
busy: false,
|
|
2482
2929
|
...finalMessages ? { messages: finalMessages } : {},
|
|
2483
|
-
...turnCostUsd ? { costUsd: current.costUsd + turnCostUsd } : {}
|
|
2930
|
+
...turnCostUsd ? { costUsd: current.costUsd + turnCostUsd } : {},
|
|
2931
|
+
...escalations.length ? { escalations: [...current.escalations ?? [], ...escalations] } : {}
|
|
2484
2932
|
});
|
|
2485
2933
|
controller.close();
|
|
2486
2934
|
}
|
|
@@ -2563,10 +3011,8 @@ function setCors(c, origin) {
|
|
|
2563
3011
|
function jsonError(c, status, code, message) {
|
|
2564
3012
|
return c.json({ error: { code, message } }, status);
|
|
2565
3013
|
}
|
|
2566
|
-
function
|
|
2567
|
-
|
|
2568
|
-
if (forwarded) return forwarded.split(",")[0]?.trim() ?? "unknown";
|
|
2569
|
-
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));
|
|
2570
3016
|
}
|
|
2571
3017
|
function sanitizeContext(context) {
|
|
2572
3018
|
if (!context) return {};
|
|
@@ -2585,6 +3031,18 @@ function sanitizeContext(context) {
|
|
|
2585
3031
|
function validConversationId(value) {
|
|
2586
3032
|
return typeof value === "string" && /^[A-Za-z0-9_-]{8,128}$/.test(value);
|
|
2587
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
|
+
}
|
|
2588
3046
|
function titleFromText(value) {
|
|
2589
3047
|
const title = value.trim().replace(/\s+/g, " ");
|
|
2590
3048
|
return title.slice(0, 42) + (title.length > 42 ? "\u2026" : "");
|
|
@@ -2633,8 +3091,9 @@ function titleFromClientMessages(messages) {
|
|
|
2633
3091
|
import { mkdir, readFile as readFile2, readdir, rename, rm, writeFile } from "fs/promises";
|
|
2634
3092
|
import { randomUUID as randomUUID5 } from "crypto";
|
|
2635
3093
|
import { join, resolve } from "path";
|
|
2636
|
-
import { parseFrankieConfig } from "@frankieai/core";
|
|
3094
|
+
import { parseFrankieConfig, resolvePersonaConfig as resolvePersonaConfig2 } from "@frankieai/core";
|
|
2637
3095
|
var MEMBERS_FILE = "__members.json";
|
|
3096
|
+
var SUBSCRIPTIONS_FILE = "__subscriptions.json";
|
|
2638
3097
|
function publicVercelLink(link) {
|
|
2639
3098
|
const { accessToken: _token, ...rest } = link;
|
|
2640
3099
|
return rest;
|
|
@@ -2687,10 +3146,15 @@ function toPublicTenant(record) {
|
|
|
2687
3146
|
}))
|
|
2688
3147
|
],
|
|
2689
3148
|
locked: record.locked ?? false,
|
|
3149
|
+
apiAutoRefresh: record.apiAutoRefresh ?? false,
|
|
3150
|
+
...record.suggestedTheme ? { suggestedTheme: record.suggestedTheme } : {},
|
|
2690
3151
|
...record.repositoryInstall ? { repositoryInstall: record.repositoryInstall } : {},
|
|
3152
|
+
...record.personaInstalls ? { personaInstalls: record.personaInstalls } : {},
|
|
2691
3153
|
...record.vercel ? { vercel: publicVercelLink(record.vercel) } : {},
|
|
2692
3154
|
theme: config.theme,
|
|
2693
3155
|
features: config.features,
|
|
3156
|
+
knowledgeBase: config.knowledgeBase,
|
|
3157
|
+
personas: config.personas,
|
|
2694
3158
|
agent: {
|
|
2695
3159
|
greeting: config.agent.greeting,
|
|
2696
3160
|
suggestedPrompts: config.agent.suggestedPrompts,
|
|
@@ -2743,6 +3207,8 @@ var FileTenantStore = class _FileTenantStore {
|
|
|
2743
3207
|
byId = /* @__PURE__ */ new Map();
|
|
2744
3208
|
members = /* @__PURE__ */ new Map();
|
|
2745
3209
|
// by `${org}\n${login}`
|
|
3210
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
3211
|
+
// by org (owner id)
|
|
2746
3212
|
loaded = false;
|
|
2747
3213
|
static memberKey(org, login) {
|
|
2748
3214
|
return `${org}
|
|
@@ -2759,7 +3225,7 @@ ${login.toLowerCase()}`;
|
|
|
2759
3225
|
const files = await readdir(this.dir).catch(() => []);
|
|
2760
3226
|
for (const file of files) {
|
|
2761
3227
|
if (!file.endsWith(".json")) continue;
|
|
2762
|
-
if (file === MEMBERS_FILE) continue;
|
|
3228
|
+
if (file === MEMBERS_FILE || file === SUBSCRIPTIONS_FILE) continue;
|
|
2763
3229
|
try {
|
|
2764
3230
|
const record = JSON.parse(await readFile2(join(this.dir, file), "utf8"));
|
|
2765
3231
|
record.config = parseFrankieConfig(record.config);
|
|
@@ -2774,6 +3240,11 @@ ${login.toLowerCase()}`;
|
|
|
2774
3240
|
}
|
|
2775
3241
|
} catch {
|
|
2776
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
|
+
}
|
|
2777
3248
|
this.loaded = true;
|
|
2778
3249
|
}
|
|
2779
3250
|
async persistMembers() {
|
|
@@ -2798,18 +3269,38 @@ ${login.toLowerCase()}`;
|
|
|
2798
3269
|
const lower = login.toLowerCase();
|
|
2799
3270
|
return [...this.members.values()].filter((m) => m.login === lower);
|
|
2800
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
|
+
}
|
|
2801
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
|
+
}
|
|
2802
3290
|
this.byId.set(record.id, record);
|
|
2803
3291
|
this.index.set(record.config.auth.publishableKey, record);
|
|
3292
|
+
for (const persona of record.config.personas) this.index.set(persona.publishableKey, record);
|
|
2804
3293
|
}
|
|
2805
3294
|
async byPublishableKey(key) {
|
|
2806
3295
|
await this.load();
|
|
2807
3296
|
const record = this.index.get(key);
|
|
2808
3297
|
if (!record) return null;
|
|
2809
|
-
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];
|
|
2810
3301
|
if (!providerApiKey) return null;
|
|
2811
3302
|
return {
|
|
2812
|
-
config
|
|
3303
|
+
config,
|
|
2813
3304
|
secrets: {
|
|
2814
3305
|
...record.jwtSecret ? { jwtSecret: record.jwtSecret } : {},
|
|
2815
3306
|
sessionSecret: record.sessionSecret,
|
|
@@ -2833,29 +3324,40 @@ ${login.toLowerCase()}`;
|
|
|
2833
3324
|
throw new Error(`Publishable key already belongs to tenant "${existing.id}".`);
|
|
2834
3325
|
}
|
|
2835
3326
|
this.remember(record);
|
|
2836
|
-
await
|
|
2837
|
-
await writeFile(join(this.dir, `${record.id}.json`), JSON.stringify(record, null, 2), "utf8");
|
|
3327
|
+
await this.write(record);
|
|
2838
3328
|
}
|
|
2839
3329
|
async compareAndSwap(previous, next) {
|
|
2840
3330
|
await this.load();
|
|
2841
3331
|
if (this.byId.get(previous.id) !== previous) return false;
|
|
2842
3332
|
this.remember(next);
|
|
2843
|
-
await
|
|
2844
|
-
await writeFile(join(this.dir, `${next.id}.json`), JSON.stringify(next, null, 2), "utf8");
|
|
3333
|
+
await this.write(next);
|
|
2845
3334
|
return true;
|
|
2846
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
|
+
}
|
|
2847
3348
|
async delete(id) {
|
|
2848
3349
|
await this.load();
|
|
2849
3350
|
const record = this.byId.get(id);
|
|
2850
3351
|
if (!record) return;
|
|
2851
3352
|
this.byId.delete(id);
|
|
2852
3353
|
this.index.delete(record.config.auth.publishableKey);
|
|
3354
|
+
for (const persona of record.config.personas) this.index.delete(persona.publishableKey);
|
|
2853
3355
|
await rm(join(this.dir, `${id}.json`), { force: true });
|
|
2854
3356
|
}
|
|
2855
3357
|
};
|
|
2856
3358
|
|
|
2857
3359
|
// src/control/postgres-store.ts
|
|
2858
|
-
import { parseFrankieConfig as parseFrankieConfig2 } from "@frankieai/core";
|
|
3360
|
+
import { parseFrankieConfig as parseFrankieConfig2, resolvePersonaConfig as resolvePersonaConfig3 } from "@frankieai/core";
|
|
2859
3361
|
var PostgresTenantStore = class {
|
|
2860
3362
|
snapshots = /* @__PURE__ */ new WeakMap();
|
|
2861
3363
|
db;
|
|
@@ -2902,6 +3404,28 @@ var PostgresTenantStore = class {
|
|
|
2902
3404
|
)`
|
|
2903
3405
|
);
|
|
2904
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
|
+
};
|
|
2905
3429
|
}
|
|
2906
3430
|
memberRow(row) {
|
|
2907
3431
|
return {
|
|
@@ -2963,6 +3487,23 @@ var PostgresTenantStore = class {
|
|
|
2963
3487
|
await this.ensureReady();
|
|
2964
3488
|
await this.db.query(`DELETE FROM frankie_org_members WHERE org = $1 AND login = $2`, [org, login.toLowerCase()]);
|
|
2965
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
|
+
}
|
|
2966
3507
|
async membershipsForLogin(login) {
|
|
2967
3508
|
await this.ensureReady();
|
|
2968
3509
|
const { rows } = await this.db.query(`SELECT * FROM frankie_org_members WHERE login = $1`, [login.toLowerCase()]);
|
|
@@ -2976,19 +3517,27 @@ var PostgresTenantStore = class {
|
|
|
2976
3517
|
}
|
|
2977
3518
|
async byPublishableKey(key) {
|
|
2978
3519
|
await this.ensureReady();
|
|
2979
|
-
|
|
2980
|
-
if (rows.length === 0
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
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;
|
|
2992
3541
|
}
|
|
2993
3542
|
async list(ownerId) {
|
|
2994
3543
|
await this.ensureReady();
|
|
@@ -3127,6 +3676,53 @@ var PostgresSessionStore = class {
|
|
|
3127
3676
|
}
|
|
3128
3677
|
};
|
|
3129
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
|
+
|
|
3130
3726
|
// src/control/resolve-store.ts
|
|
3131
3727
|
async function resolveStores(env = process.env) {
|
|
3132
3728
|
const url = env["DATABASE_URL"];
|
|
@@ -3139,10 +3735,11 @@ async function resolveStores(env = process.env) {
|
|
|
3139
3735
|
});
|
|
3140
3736
|
const tenants = new PostgresTenantStore({ db: pool, env });
|
|
3141
3737
|
const sessions = new PostgresSessionStore({ db: pool });
|
|
3142
|
-
|
|
3738
|
+
const knowledge = new PostgresKnowledgeStore({ db: pool });
|
|
3739
|
+
return { tenants, sessions, knowledge, describe: "postgres" };
|
|
3143
3740
|
}
|
|
3144
3741
|
const dir = env["FRANKIE_TENANTS_DIR"] ?? "./.frankie/tenants";
|
|
3145
|
-
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}` };
|
|
3146
3743
|
}
|
|
3147
3744
|
async function resolveTenantStore(env = process.env) {
|
|
3148
3745
|
const url = env["DATABASE_URL"];
|
|
@@ -3174,6 +3771,8 @@ export {
|
|
|
3174
3771
|
MemoryRateLimiter,
|
|
3175
3772
|
MemorySessionStore,
|
|
3176
3773
|
newSessionId,
|
|
3774
|
+
resolveEmbeddingConfig,
|
|
3775
|
+
embedTexts,
|
|
3177
3776
|
TenantRegistry,
|
|
3178
3777
|
SingleTenantStore,
|
|
3179
3778
|
MemoryTenantStore,
|
|
@@ -3190,6 +3789,7 @@ export {
|
|
|
3190
3789
|
securitySchemes,
|
|
3191
3790
|
applySecurity,
|
|
3192
3791
|
collectParameters,
|
|
3792
|
+
describeSchemaInputs,
|
|
3193
3793
|
buildUrl,
|
|
3194
3794
|
slug,
|
|
3195
3795
|
isRecord,
|
|
@@ -3207,4 +3807,4 @@ export {
|
|
|
3207
3807
|
resolveStores,
|
|
3208
3808
|
resolveTenantStore
|
|
3209
3809
|
};
|
|
3210
|
-
//# sourceMappingURL=chunk-
|
|
3810
|
+
//# sourceMappingURL=chunk-WEHJH4FL.js.map
|