@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.
@@ -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) },
@@ -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 activeSkills2,
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: toolset.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: activeSkills2(config, availableTools).map(({ id, name, revision }) => ({ id, name, revision })) };
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 = clientIp(c);
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
- const session = await sessions.get(claims.sessionId);
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.busy) {
2406
- 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
+ });
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 clientIp(c) {
2567
- const forwarded = c.req.header("x-forwarded-for");
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 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];
2810
3301
  if (!providerApiKey) return null;
2811
3302
  return {
2812
- config: record.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 mkdir(this.dir, { recursive: true });
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 mkdir(this.dir, { recursive: true });
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
- const { rows } = await this.db.query(`SELECT record FROM frankie_tenants WHERE publishable_key = $1`, [key]);
2980
- if (rows.length === 0 || !rows[0]) return null;
2981
- const record = this.parse(rows[0]);
2982
- const providerApiKey = this.env[record.config.model.apiKeyEnv] ?? this.env[this.providerKeyEnv];
2983
- if (!providerApiKey) return null;
2984
- return {
2985
- config: record.config,
2986
- secrets: {
2987
- ...record.jwtSecret ? { jwtSecret: record.jwtSecret } : {},
2988
- sessionSecret: record.sessionSecret,
2989
- providerApiKey
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
- return { tenants, sessions, describe: "postgres" };
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-7J5Z5MIK.js.map
3810
+ //# sourceMappingURL=chunk-WEHJH4FL.js.map