@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.
@@ -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
- return safeEqual(key, this.resolved.config.auth.publishableKey) ? this.resolved : null;
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. Its publishable key is its lookup key. */
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
- return this.byKey.get(key) ?? null;
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
- capabilities.push({ operationId, method: httpMethod, path, ...summary ? { summary } : {}, authGap });
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 = resolveRef(doc, resolved["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 = resolveRef(doc, json["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
- const value = input[param.name];
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}}`, encodeURIComponent(String(input[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 tools = dedupe([...platform, ...mcp.tools, ...api.tools], options.onWarning);
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 Type4 } from "@earendil-works/pi-ai";
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: Type4.Unsafe({
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 = clientIp(c);
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
- const session = await sessions.get(claims.sessionId);
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.busy) {
2450
- return jsonError(c, 409, "internal", "The assistant is still answering your previous message.");
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 clientIp(c) {
2611
- const forwarded = c.req.header("x-forwarded-for");
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 providerApiKey = this.env[record.config.model.apiKeyEnv] ?? this.env[this.providerKeyEnv];
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: record.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 mkdir(this.dir, { recursive: true });
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 mkdir(this.dir, { recursive: true });
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
- const { rows } = await this.db.query(`SELECT record FROM frankie_tenants WHERE publishable_key = $1`, [key]);
3024
- if (rows.length === 0 || !rows[0]) return null;
3025
- const record = this.parse(rows[0]);
3026
- const providerApiKey = this.env[record.config.model.apiKeyEnv] ?? this.env[this.providerKeyEnv];
3027
- if (!providerApiKey) return null;
3028
- return {
3029
- config: record.config,
3030
- secrets: {
3031
- ...record.jwtSecret ? { jwtSecret: record.jwtSecret } : {},
3032
- sessionSecret: record.sessionSecret,
3033
- providerApiKey
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
- return { tenants, sessions, describe: "postgres" };
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-HPYT6ZIU.js.map
3810
+ //# sourceMappingURL=chunk-WEHJH4FL.js.map