@frankieai/core 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -26,12 +26,31 @@ function matches(toolName, pattern) {
26
26
  if (toolName === pattern) return true;
27
27
  return toolName.startsWith(`${pattern}__`);
28
28
  }
29
+ var SKILL_TOOL_NAME = "frankie__use_skill";
29
30
  function describeTool(toolName, input) {
31
+ if (toolName === SKILL_TOOL_NAME) {
32
+ const skill = typeof input === "object" && input !== null ? input["skill"] : void 0;
33
+ return typeof skill === "string" && skill.trim() ? `Using skill: ${skill.trim()}` : "Using a skill";
34
+ }
35
+ const builtIn = /^frankie__([a-z0-9]+)__(.+)$/.exec(toolName);
36
+ if (builtIn) {
37
+ const [, group, action] = builtIn;
38
+ if (group === "docs") return "Search the docs";
39
+ if (group === "escalate") return "Escalate to a person";
40
+ return humanise(action ?? toolName);
41
+ }
30
42
  const mcp = /^mcp__([^_]+(?:_[^_]+)*)__(.+)$/.exec(toolName);
31
43
  if (mcp) {
32
44
  const [, server, tool] = mcp;
33
45
  return `${humanise(tool ?? toolName)} \xB7 ${server}`;
34
46
  }
47
+ if (toolName.startsWith("api__")) {
48
+ const rest = toolName.slice("api__".length);
49
+ const sep = rest.indexOf("__");
50
+ const operation = (sep >= 0 ? rest.slice(sep + 2) : rest).replace(/__+/g, "_");
51
+ const verb = /^(get|post|put|patch|delete|head|options)_(.+)$/i.exec(operation);
52
+ return verb ? `${verb[1].toUpperCase()} ${humanise(verb[2])}` : humanise(operation);
53
+ }
35
54
  const inputRecord = typeof input === "object" && input !== null ? input : {};
36
55
  const hint = inputRecord["path"] ?? inputRecord["url"] ?? inputRecord["query"] ?? inputRecord["command"];
37
56
  return typeof hint === "string" ? `${humanise(toolName)}: ${truncate(hint, 60)}` : humanise(toolName);
@@ -71,16 +90,21 @@ function toolDescriptions(config, toolIds) {
71
90
  for (const source of config.apis) {
72
91
  for (const capability of source.capabilities) {
73
92
  const id = `api__${source.name}__${capability.operationId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 48)}`;
74
- if (wanted.has(id)) found.set(id, `${capability.method} ${capability.path}` + (capability.summary ? ` \u2014 ${capability.summary}` : ""));
93
+ if (wanted.has(id)) {
94
+ found.set(id, {
95
+ description: `${capability.method} ${capability.path}` + (capability.summary ? ` \u2014 ${capability.summary}` : ""),
96
+ ...capability.inputs ? { inputs: capability.inputs } : {}
97
+ });
98
+ }
75
99
  }
76
100
  }
77
101
  for (const source of config.mcpServers) {
78
102
  for (const tool of source.discoveredTools) {
79
103
  const id = `mcp__${source.name}__${tool.name}`;
80
- if (wanted.has(id)) found.set(id, tool.description || tool.name);
104
+ if (wanted.has(id)) found.set(id, { description: tool.description || tool.name, ...tool.inputs ? { inputs: tool.inputs } : {} });
81
105
  }
82
106
  }
83
- return toolIds.map((id) => ({ id, description: found.get(id) ?? id }));
107
+ return toolIds.map((id) => ({ id, ...found.get(id) ?? { description: id } }));
84
108
  }
85
109
  function suggestedPromptsFromSkills(config) {
86
110
  const seen = /* @__PURE__ */ new Set();
@@ -118,6 +142,8 @@ function draftSkill(goal, tools) {
118
142
  "",
119
143
  "Required inputs: note what each tool below needs, and ask the user for anything missing before calling it.",
120
144
  "",
145
+ "Quality bar: what makes a good result, not just a valid request. List the optional fields a complete item has (a description, a level, a length\u2026) and what a good value looks like; draft them from the conversation, ask for facts only the user knows, and show the full item for confirmation before saving.",
146
+ "",
121
147
  "Steps:",
122
148
  ...tools.map((tool, index) => `${index + 1}. ${tool.id}${tool.label ? ` \u2014 ${tool.label}` : ""}`),
123
149
  "Call only the tool that matches the user's actual request; respect the signed-in user's own permissions and confirm before any consequential write.",
@@ -223,7 +249,19 @@ var featuresConfigSchema = z2.object({
223
249
  * Internal dashboard mode: help administer existing workspaces, tools and
224
250
  * conversations without restarting the onboarding procedure.
225
251
  */
226
- configurationAssistant: z2.boolean().default(false)
252
+ configurationAssistant: z2.boolean().default(false),
253
+ /**
254
+ * Human escalation: give the agent a built-in tool to hand a conversation to
255
+ * the workspace's team when it can't resolve something or the visitor asks
256
+ * for a person. Each escalation is recorded on the conversation (a dashboard
257
+ * inbox) and, when `webhookUrl` is set, POSTed there (Slack, Zapier, a
258
+ * ticketing intake…). For support/landing-page agents.
259
+ */
260
+ escalation: z2.object({
261
+ enabled: z2.boolean().default(false),
262
+ /** Optional HTTPS endpoint that receives each escalation as JSON. */
263
+ webhookUrl: z2.string().url().startsWith("https://").max(2e3).optional()
264
+ }).prefault({})
227
265
  }).prefault({});
228
266
  var mcpServerSchema = z2.object({
229
267
  name: z2.string().min(1).regex(/^[a-z0-9][a-z0-9_-]*$/i, "MCP server names must be alphanumeric with - or _"),
@@ -237,7 +275,8 @@ var mcpServerSchema = z2.object({
237
275
  enabled: z2.boolean().default(true),
238
276
  /** Explicit tool selection. Omitted keeps legacy all-tools behavior; [] disables all. */
239
277
  selectedTools: z2.array(z2.string().min(1)).optional(),
240
- discoveredTools: z2.array(z2.object({ name: z2.string(), description: z2.string().optional() })).default([]),
278
+ /** Tools the server listed at discovery; `inputs` summarizes each one's input schema for skill writing. */
279
+ discoveredTools: z2.array(z2.object({ name: z2.string(), description: z2.string().optional(), inputs: z2.string().optional() })).default([]),
241
280
  /** `http` covers streamable HTTP; `sse` is the legacy transport. */
242
281
  transport: z2.enum(["http", "sse"]).default("http"),
243
282
  url: z2.string().url(),
@@ -257,6 +296,14 @@ var apiCapabilitySchema = z2.object({
257
296
  method: z2.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
258
297
  path: z2.string().min(1),
259
298
  summary: z2.string().optional(),
299
+ /**
300
+ * One line naming what the operation takes — its path/query parameters and
301
+ * the request body's top-level fields, each marked required where the spec
302
+ * says so, with its description or allowed values. This is what lets a
303
+ * skill say which inputs to collect before calling the tool (and which it
304
+ * must never invent), instead of "check the tool's own parameters".
305
+ */
306
+ inputs: z2.string().optional(),
260
307
  /**
261
308
  * True when this is a write operation the spec requires auth for, but the
262
309
  * source has no way to authorize it as the specific calling user — no
@@ -364,10 +411,26 @@ var toolPolicySchema = z2.object({
364
411
  */
365
412
  denyByDefault: z2.boolean().default(true)
366
413
  });
414
+ var STARTER_PROMPT_ICONS = [
415
+ "sparkles",
416
+ "wand",
417
+ "book",
418
+ "chat",
419
+ "chart",
420
+ "bolt",
421
+ "music",
422
+ "mic",
423
+ "image",
424
+ "compass",
425
+ "lightbulb",
426
+ "file"
427
+ ];
367
428
  var starterPromptSchema = z2.object({
368
429
  title: z2.string().trim().min(1).max(80),
369
430
  description: z2.string().trim().max(160).optional(),
370
- message: z2.string().trim().min(1).max(2e3)
431
+ message: z2.string().trim().min(1).max(2e3),
432
+ /** One of STARTER_PROMPT_ICONS; unknown values fall back to a default icon. */
433
+ icon: z2.enum(STARTER_PROMPT_ICONS).optional()
371
434
  });
372
435
  var agentConfigSchema = z2.object({
373
436
  /** Prepended to the runtime's own operating instructions. */
@@ -441,6 +504,46 @@ var brandingConfigSchema = z2.object({
441
504
  avatarUrl: z2.string().url().optional(),
442
505
  launcherLabel: z2.string().default("Need a hand?")
443
506
  });
507
+ var knowledgeSourceSchema = z2.object({
508
+ url: z2.string().url().startsWith("https://").max(2e3)
509
+ });
510
+ var knowledgeBaseSchema = z2.object({
511
+ enabled: z2.boolean().default(false),
512
+ sources: z2.array(knowledgeSourceSchema).max(20).default([]),
513
+ /** Cap on pages fetched per source during a crawl. */
514
+ maxPages: z2.number().int().positive().max(200).default(40)
515
+ }).prefault({});
516
+ var personaProfileSchema = z2.object({
517
+ /** Stable lowercase slug, unique within the workspace. Baked into env var, branch and mount module. */
518
+ id: z2.string().min(1).max(40).regex(/^[a-z0-9][a-z0-9-]*$/, "Persona ids are lowercase slugs."),
519
+ /** Tab label and install-PR title suffix, e.g. "Knowledge base". */
520
+ label: z2.string().trim().min(1).max(60),
521
+ /** This persona's own publishable key. One key maps to exactly one persona. */
522
+ publishableKey: z2.string().min(8),
523
+ /** App-Router path globs this persona shows on. Empty = every page no other agent claims. */
524
+ pagePatterns: z2.array(z2.string().trim().min(1).max(200)).max(20).default([]),
525
+ /**
526
+ * Whether visitors may chat with this persona without signing in to the host
527
+ * app. Unset inherits the workspace's `auth.allowAnonymous`. A support or docs
528
+ * agent on public pages needs this on, or every logged-out visitor is told to
529
+ * sign in.
530
+ */
531
+ allowAnonymous: z2.boolean().optional(),
532
+ /**
533
+ * Shallow-merged over the workspace branding. Each field is optional with no
534
+ * default, so only the keys the persona sets override the workspace's — an
535
+ * absent field keeps the workspace value rather than resetting it.
536
+ */
537
+ branding: z2.object({
538
+ name: z2.string().optional(),
539
+ tagline: z2.string().optional(),
540
+ avatarUrl: z2.string().url().optional(),
541
+ launcherLabel: z2.string().optional()
542
+ }).optional(),
543
+ agent: agentConfigSchema.prefault({}),
544
+ features: featuresConfigSchema,
545
+ knowledgeBase: knowledgeBaseSchema
546
+ });
444
547
  var frankieConfigSchema = z2.object({
445
548
  tenantId: z2.string().min(1),
446
549
  branding: brandingConfigSchema.prefault({}),
@@ -455,7 +558,18 @@ var frankieConfigSchema = z2.object({
455
558
  skills: z2.array(skillSchema).max(30).default([]),
456
559
  limits: limitsConfigSchema.prefault({}),
457
560
  /** Opt-in end-user chat capabilities (attachments, model picker). */
458
- features: featuresConfigSchema
561
+ features: featuresConfigSchema,
562
+ /** Documentation the agent answers from, indexed for semantic retrieval. */
563
+ knowledgeBase: knowledgeBaseSchema,
564
+ /** App-Router path globs the primary (product) agent shows on. Empty = every page no persona claims. */
565
+ pagePatterns: z2.array(z2.string().trim().min(1).max(200)).max(20).default([]),
566
+ /**
567
+ * Additional embedded personas on this workspace. Empty for a single-agent
568
+ * workspace — the top-level agent/features/knowledgeBase + `auth.publishableKey`
569
+ * is the primary persona. Each entry resolves to its own one-agent config via
570
+ * its own publishable key, so the data plane still sees one config per key.
571
+ */
572
+ personas: z2.array(personaProfileSchema).max(8).default([])
459
573
  });
460
574
  var LEAKY_MODEL_MIGRATION = {
461
575
  "accounts/fireworks/models/kimi-k2p7-code": "accounts/fireworks/models/kimi-k3"
@@ -471,8 +585,64 @@ function parseFrankieConfig(input) {
471
585
  function defineFrankieConfig(config) {
472
586
  return config;
473
587
  }
588
+ function resolvePersonaConfig(config, publishableKey) {
589
+ if (publishableKey === config.auth.publishableKey) return config;
590
+ const persona = config.personas.find((entry) => entry.publishableKey === publishableKey);
591
+ if (!persona) return null;
592
+ return parseFrankieConfig({
593
+ ...config,
594
+ personas: [],
595
+ branding: { ...config.branding, ...persona.branding ?? {} },
596
+ auth: { ...config.auth, publishableKey: persona.publishableKey, allowAnonymous: persona.allowAnonymous ?? config.auth.allowAnonymous },
597
+ pagePatterns: persona.pagePatterns,
598
+ agent: persona.agent,
599
+ features: persona.features,
600
+ knowledgeBase: persona.knowledgeBase
601
+ });
602
+ }
603
+ function normalizeRoutePath(value) {
604
+ let path = (value || "/").split(/[?#]/)[0].trim();
605
+ if (!path.startsWith("/")) path = "/" + path;
606
+ if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
607
+ return path;
608
+ }
609
+ function matchesPagePattern(pathname, pattern) {
610
+ const path = normalizeRoutePath(pathname);
611
+ const pat = normalizeRoutePath(pattern);
612
+ const trailingDeep = pat.endsWith("/**");
613
+ const core = trailingDeep ? pat.slice(0, -3) : pat;
614
+ const escaped = core.replace(/[.+^${}()|[\]\\]/g, "\\$&");
615
+ const body = escaped.split("**").map((chunk) => chunk.replace(/\*/g, "[^/]*")).join(".*");
616
+ return new RegExp("^" + body + (trailingDeep ? "(?:/.*)?" : "") + "$").test(path);
617
+ }
618
+ function matchesAnyPagePattern(pathname, patterns) {
619
+ return patterns.length === 0 || patterns.some((pattern) => matchesPagePattern(pathname, pattern));
620
+ }
474
621
 
475
622
  // src/install.ts
623
+ function personaEnvKey(personaId) {
624
+ return "NEXT_PUBLIC_FRANKIE_KEY_" + personaId.toUpperCase().replace(/[^A-Z0-9]/g, "_");
625
+ }
626
+ var STANDARD_AGENT_IDS = ["support", "docs"];
627
+ function installAgentIds(personaIds = []) {
628
+ const custom = [...new Set(personaIds)].filter((id) => /^[a-z0-9][a-z0-9-]*$/.test(id) && !STANDARD_AGENT_IDS.includes(id)).sort();
629
+ return [...STANDARD_AGENT_IDS, ...custom];
630
+ }
631
+ function installBranch(workspaceId, baseSha, personaId) {
632
+ return "frankie/install-" + workspaceId + (personaId ? "--" + personaId : "") + "-" + baseSha.slice(0, 8);
633
+ }
634
+ function plainText(value, max = 80) {
635
+ const text = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/[`<>[\]()*_#|!\\]/g, "").replace(/\s+/g, " ").trim();
636
+ return text.length > max ? text.slice(0, max - 1).trimEnd() + "\u2026" : text;
637
+ }
638
+ function installTitle(personaLabel) {
639
+ return personaLabel ? "Install Frankie: " + plainText(personaLabel, 60) : "Install Frankie";
640
+ }
641
+ function matchesInstallBranch(ref, workspaceId, personaId) {
642
+ const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
643
+ const mid = personaId ? escape(workspaceId) + "--?" + escape(personaId) : escape(workspaceId);
644
+ return new RegExp("^frankie/install-" + mid + "-[0-9a-f]{8}$").test(ref);
645
+ }
476
646
  function assertNoCommittedFrankieKeys(files) {
477
647
  for (const file of files) {
478
648
  if (/\bpk_live_[0-9a-f]{24}\b/i.test(file.content) || /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/.test(file.content)) {
@@ -480,8 +650,17 @@ function assertNoCommittedFrankieKeys(files) {
480
650
  }
481
651
  }
482
652
  }
653
+ var ENV_BLOCK_START = "# --- Frankie (managed) ---";
654
+ var ENV_BLOCK_END = "# --- end Frankie ---";
655
+ var LEGACY_ENV_COMMENTS = [
656
+ "# Public at runtime, but inject it through your deployment environment instead of committing a workspace value.",
657
+ "# Server only: use the secret from this workspace. Never commit its value.",
658
+ "# This persona's publishable key. Public at runtime; set it in your deployment environment, don't commit a value."
659
+ ];
483
660
  function planHostedInstall(input) {
484
661
  const { files, workspace } = input;
662
+ const product = !input.persona;
663
+ const anonymous = workspace.allowAnonymous;
485
664
  const layouts = Object.keys(files).filter((p) => /(?:^|\/)(?:src\/)?app(?:\/\([^/]+\))?\/layout\.tsx$/.test(p));
486
665
  const roots = new Set(layouts.map((path) => path.replace(/app\/(?:\([^/]+\)\/)?layout\.tsx$/, "app/")));
487
666
  if (roots.size > 1) throw new Error("Multiple Next.js applications found. Run the installer from the intended app with the CLI; Frankie will not guess which app to modify.");
@@ -495,34 +674,70 @@ function planHostedInstall(input) {
495
674
  const pkg = JSON.parse(files[pkgPath] ?? "{}");
496
675
  if (!pkg.dependencies?.next) throw new Error("The selected app must have Next.js in dependencies.");
497
676
  const out = /* @__PURE__ */ new Map();
677
+ const deletions = [];
498
678
  const notes = [];
499
- const existingMount = files[layoutPath].includes("FrankieWidgetMount");
500
679
  const sourceRoot = appRoot.endsWith("src/app/") ? root + "src/" : root;
501
680
  const tokenPath = appRoot + "api/frankie/token/route.ts";
502
681
  const healthPath = appRoot + "api/frankie/health/route.ts";
503
- const auth = detectAuth(files, sourceRoot, pkg.dependencies);
504
- const authStrategy = workspace.allowAnonymous ? "anonymous" : files[tokenPath] ? "existing" : auth?.strategy ?? "unconfigured";
682
+ const agentsPath = appRoot + "frankie/FrankieAgents.tsx";
683
+ const chatModulePath = appRoot + "frankie/FrankieChatPage.tsx";
684
+ const importer = moduleImporter(files, root, sourceRoot);
685
+ const auth = detectAuth(files, sourceRoot, pkg.dependencies, (module) => importer(tokenPath, module));
686
+ const existingToken = files[tokenPath];
687
+ const authStrategy = anonymous ? "anonymous" : auth?.strategy ?? (existingToken && !existingToken.includes("AUTH_NOT_CONFIGURED") ? "existing" : "unconfigured");
505
688
  const needsAuthSetup = authStrategy === "unconfigured";
506
- if (needsAuthSetup) notes.push("No session lookup was detected. Export getCurrentUser from lib/frankie-auth.ts (see the generated token endpoint); until then it answers 501 and chat reports that sign-in is not wired.");
507
- if (authStrategy === "supabase") notes.push("Detected Supabase auth: the token endpoint verifies the user's Supabase access token and the widget forwards it automatically. Users must be signed in through Supabase.");
508
- pkg.dependencies = { ...pkg.dependencies, "@frankieai/react": pkg.dependencies["@frankieai/react"] ?? "^0.2.1", ...!workspace.allowAnonymous ? { jose: pkg.dependencies.jose ?? "^6.0.11" } : {} };
509
- out.set(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
510
- const authProp = workspace.allowAnonymous ? "" : " getUserToken={getUserToken}";
511
- const shared = `import { FrankieProvider, FrankieWidget, FrankieChatPage } from "@frankieai/react";
512
- import "@frankieai/react/styles.css";
513
- `;
514
- const clientAuthImport = auth?.client && !workspace.allowAnonymous ? `import { ${auth.client.name} } from "@/lib/supabase";
689
+ if (product && needsAuthSetup) notes.push("No session lookup was detected. Export getCurrentUser from lib/frankie-auth.ts (see the generated token endpoint); until then it answers 501 and chat reports that sign-in is not wired.");
690
+ if (product && authStrategy === "supabase") notes.push("Detected Supabase auth: the token endpoint verifies the user's Supabase access token and the widget forwards it automatically. Users must be signed in through Supabase.");
691
+ pkg.dependencies = withDependencies(pkg.dependencies, { "@frankieai/react": "^0.2.1", ...!anonymous ? { jose: "^6.0.11" } : {} });
692
+ out.set(pkgPath, renderJson(pkg, files[pkgPath]));
693
+ let manifest = {};
694
+ try {
695
+ const parsed = JSON.parse(files[root + "frankie.install.json"] ?? "{}");
696
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) manifest = parsed;
697
+ } catch {
698
+ }
699
+ const priorChat = manifest.workspaceId === workspace.id && (manifest.chatPath === "/chat" || manifest.chatPath === "/frankie/chat") ? manifest.chatPath : void 0;
700
+ const chatPath = priorChat ?? (files[integrationAppRoot + "chat/page.tsx"] ? "/frankie/chat" : "/chat");
701
+ let legacyChat = false;
702
+ for (const path of Object.keys(files).sort()) {
703
+ if (!path.startsWith(appRoot)) continue;
704
+ const legacy = path.match(/(?:^|\/)frankie\/(?:FrankieWidgetMount|FrankieSetupChat)\.tsx$/);
705
+ if (legacy) {
706
+ const exportsChat = /\bWorkspaceChatPage\b/.test(files[path]);
707
+ legacyChat ||= exportsChat;
708
+ out.set(path, `// Frankie now mounts every agent from FrankieAgents in the root layout.
709
+ // This module stays so existing imports keep working; it renders nothing.
710
+ export function FrankieWidgetMount() {
711
+ return null;
712
+ }
713
+ ${exportsChat ? `export { WorkspaceChatPage } from "${relativeImport(path, chatModulePath.slice(0, -4))}";
714
+ ` : ""}`);
715
+ }
716
+ if (/(?:^|\/)frankie\/FrankieWidget\.[a-z0-9][a-z0-9-]*\.tsx$/.test(path)) deletions.push(path);
717
+ }
718
+ const clientAuth = anonymous ? void 0 : auth?.client;
719
+ const clientAuthImport = clientAuth ? `import { ${clientAuth.name} } from "${importer(agentsPath, "lib/supabase")}";
515
720
  ` : "";
516
- const getToken = workspace.allowAnonymous ? "" : auth?.client ? `
517
- async function getUserToken() {
518
- const session = (await ${auth.client.name}?.auth.getSession())?.data.session;
519
- if (!session?.access_token) return undefined;
520
- const response = await fetch("/api/frankie/token", { method: "POST", headers: { Authorization: \`Bearer \${session.access_token}\` } });
721
+ const tokenHelpers = anonymous ? "" : clientAuth ? `
722
+ async function supabaseToken() {
723
+ const session = (await ${clientAuth.name}?.auth.getSession())?.data.session;
724
+ return session?.access_token;
725
+ }
726
+ /** The signed-in user's Frankie token, or undefined when nobody is signed in. */
727
+ export async function getUserToken() {
728
+ const accessToken = await supabaseToken();
729
+ if (!accessToken) return undefined;
730
+ const response = await fetch("/api/frankie/token", { method: "POST", headers: { Authorization: \`Bearer \${accessToken}\` } });
521
731
  if (!response.ok) return undefined;
522
732
  return ((await response.json()) as { token?: string }).token;
523
733
  }
734
+ const optionalUserToken = getUserToken;
735
+ async function productAvailable() {
736
+ return Boolean(await supabaseToken());
737
+ }
524
738
  ` : `
525
- async function getUserToken() {
739
+ /** The signed-in user's Frankie token; throws the reason chat can't start. */
740
+ export async function getUserToken() {
526
741
  const response = await fetch("/api/frankie/token", { method: "POST", credentials: "same-origin" });
527
742
  if (response.ok) return ((await response.json()) as { token?: string }).token;
528
743
  if (response.status === 401) throw new Error("Sign in to this app to use chat.");
@@ -530,65 +745,153 @@ async function getUserToken() {
530
745
  if (response.status === 503) throw new Error("Chat is not configured on the server yet (FRANKIE_JWT_SECRET is missing).");
531
746
  throw new Error("The chat sign-in endpoint returned " + response.status + ".");
532
747
  }
748
+ /** Support and docs agents chat with visitors too: who is signed in, if anyone. */
749
+ async function optionalUserToken() {
750
+ try {
751
+ const response = await fetch("/api/frankie/token", { method: "POST", credentials: "same-origin" });
752
+ return response.ok ? ((await response.json()) as { token?: string }).token : undefined;
753
+ } catch {
754
+ return undefined;
755
+ }
756
+ }
757
+ /** The product agent is for signed-in users. 501/503 still show it, so the widget can say what to fix. */
758
+ async function productAvailable() {
759
+ try {
760
+ const response = await fetch("/api/frankie/token", { method: "POST", credentials: "same-origin" });
761
+ return response.ok || response.status === 501 || response.status === 503;
762
+ } catch {
763
+ return false;
764
+ }
765
+ }
533
766
  `;
534
- const hostApiHeaders = auth?.client && !workspace.allowAnonymous ? `
535
- headers: async () => {
536
- const session = (await ${auth.client.name}?.auth.getSession())?.data.session;
767
+ const hostApiHeaders = clientAuth ? `
768
+ headers: async (): Promise<Record<string, string>> => {
769
+ const session = (await ${clientAuth.name}?.auth.getSession())?.data.session;
537
770
  return session?.access_token ? { Authorization: \`Bearer \${session.access_token}\` } : {};
538
771
  },` : "";
539
- const hostApi = `const hostApi = {
772
+ const hostApi = `export const hostApi = {
540
773
  baseUrl: typeof window !== "undefined" ? window.location.origin : "",
541
- credentials: "${auth?.client || workspace.allowAnonymous ? "same-origin" : "include"}" as const,${hostApiHeaders}
774
+ credentials: "${clientAuth || anonymous ? "same-origin" : "include"}" as const,${hostApiHeaders}
542
775
  };
543
776
  `;
544
- const apiProp = " api={hostApi}";
545
- let priorChat;
546
- try {
547
- const previous = JSON.parse(files[root + "frankie.install.json"] ?? "{}");
548
- if (previous.workspaceId === workspace.id && ["/chat", "/frankie/chat"].includes(previous.chatPath)) priorChat = previous.chatPath;
549
- } catch {
550
- }
551
- const chatPath = priorChat ?? (files[integrationAppRoot + "chat/page.tsx"] ? "/frankie/chat" : "/chat");
552
- if (!priorChat && files[integrationAppRoot + chatPath.slice(1) + "/page.tsx"]) throw new Error("Both /chat and /frankie/chat already exist. Choose a chat route manually.");
553
- out.set(root + "frankie.install.json", JSON.stringify({ workspaceId: workspace.id, chatPath }, null, 2) + "\n");
554
- if (chatPath !== "/chat") notes.push("Existing /chat preserved. Frankie chat is available at /frankie/chat.");
555
- const mount = `"use client";
777
+ const personaIds = installAgentIds([...input.agents ?? [], ...input.persona ? [input.persona.id] : []]);
778
+ const agentsModule = `"use client";
779
+ // Generated by Frankie. Every Frankie agent in this app mounts here, once, from
780
+ // the root layout. An agent turns on when its publishable key is set in the
781
+ // deployment environment, and shows on the pages chosen for it in Frankie.
782
+ import { useEffect, useState } from "react";
556
783
  import { usePathname } from "next/navigation";
557
- ${shared}${clientAuthImport}${getToken}${hostApi}
558
- export function FrankieWidgetMount() {
559
- const pathname = usePathname();
560
- const key = process.env.NEXT_PUBLIC_FRANKIE_KEY;
561
- if (!key || pathname === ${JSON.stringify(chatPath)}) return null;
562
- return <FrankieProvider publishableKey={key}${authProp}${apiProp}><FrankieWidget /></FrankieProvider>;
784
+ import { FrankieClient, FrankieProvider, FrankieWidget, type PublicWidgetConfig } from "@frankieai/react";
785
+ import "@frankieai/react/styles.css";
786
+ ${clientAuthImport}${tokenHelpers}${hostApi}
787
+ type Agent = { id: string; key: string | undefined };
788
+ // Literal process.env reads, so Next.js inlines each key at build time. Where
789
+ // two agents' pages overlap the earlier one wins; the product agent takes
790
+ // every page no other agent claims.
791
+ const PERSONAS: Agent[] = [
792
+ ${personaIds.map((id) => ` { id: ${JSON.stringify(id)}, key: process.env.${personaEnvKey(id)} },`).join("\n")}
793
+ ];
794
+ const PRODUCT: Agent = { id: "product", key: process.env.NEXT_PUBLIC_FRANKIE_KEY };
795
+ // The full-page chat never gets a floating launcher on top of it.
796
+ const CHAT_PATHS = ["/chat", "/frankie/chat"];
797
+
798
+ type Loaded = { config: PublicWidgetConfig | undefined; pages: string[] };
799
+ const loading = new Map<string, Promise<Loaded>>();
800
+ function load(key: string): Promise<Loaded> {
801
+ let pending = loading.get(key);
802
+ if (!pending) {
803
+ pending = new FrankieClient({ publishableKey: key })
804
+ .getConfig()
805
+ .then((config) => ({ config, pages: (config as { pages?: string[] }).pages ?? [] }))
806
+ .catch(() => ({ config: undefined, pages: [] }));
807
+ loading.set(key, pending);
808
+ }
809
+ return pending;
563
810
  }
811
+
812
+ function matchesAny(pathname: string, patterns: string[]): boolean {
813
+ if (!patterns.length) return true;
814
+ const path = (pathname || "/").split(/[?#]/)[0]!.replace(/(.)\\/+$/, "$1");
815
+ return patterns.some((pattern) => {
816
+ const pat = (pattern || "/").replace(/(.)\\/+$/, "$1");
817
+ const deep = pat.endsWith("/**");
818
+ const core = (deep ? pat.slice(0, -3) : pat).replace(/[.+^\${}()|[\\]\\\\]/g, "\\\\$&");
819
+ const body = core.split("**").map((chunk) => chunk.replace(/\\*/g, "[^/]*")).join(".*");
820
+ return new RegExp("^" + body + (deep ? "(?:/.*)?" : "") + "$").test(path);
821
+ });
822
+ }
823
+
824
+ function pick(pathname: string, loaded: Record<string, Loaded>, signedIn: boolean): { agent: Agent; key: string } | undefined {
825
+ if (CHAT_PATHS.includes(pathname)) return undefined;
826
+ const live = [...PERSONAS, PRODUCT].flatMap((agent) => (agent.key ? [{ agent, key: agent.key }] : []));
827
+ // Decide once every agent's pages are known, so one never flashes in before another.
828
+ if (live.some(({ key }) => !loaded[key])) return undefined;
829
+ const personas = live.filter(({ agent, key }) => agent !== PRODUCT && loaded[key]!.config);
830
+ const claimed = personas.find(({ key }) => loaded[key]!.pages.length > 0 && matchesAny(pathname, loaded[key]!.pages));
831
+ if (claimed) return claimed;
832
+ const product = live.find(({ agent }) => agent === PRODUCT);
833
+ if (product && signedIn && matchesAny(pathname, loaded[product.key]!.pages)) return product;
834
+ return personas.find(({ key }) => loaded[key]!.pages.length === 0);
835
+ }
836
+
837
+ export function FrankieAgents() {
838
+ const pathname = usePathname() || "/";
839
+ const [loaded, setLoaded] = useState<Record<string, Loaded>>({});
840
+ useEffect(() => {
841
+ let live = true;
842
+ for (const { key } of [...PERSONAS, PRODUCT]) {
843
+ if (key) void load(key).then((result) => { if (live) setLoaded((prev) => ({ ...prev, [key]: result })); });
844
+ }
845
+ return () => { live = false; };
846
+ }, []);
847
+ ${anonymous ? ` const signedIn = true;
848
+ ` : ` const [signedIn, setSignedIn] = useState(false);
849
+ useEffect(() => {
850
+ if (!PRODUCT.key) return;
851
+ let live = true;
852
+ void productAvailable().then((ok) => { if (live) setSignedIn(ok); });
853
+ return () => { live = false; };
854
+ }, [pathname]);
855
+ `} const active = pick(pathname, loaded, signedIn);
856
+ if (!active) return null;
857
+ return (
858
+ <FrankieProvider key={active.key} publishableKey={active.key} config={loaded[active.key]?.config}${anonymous ? "" : ` getUserToken={active.agent === PRODUCT ? getUserToken : optionalUserToken}`} api={hostApi}>
859
+ <FrankieWidget />
860
+ </FrankieProvider>
861
+ );
862
+ }
863
+ `;
864
+ out.set(agentsPath, agentsModule);
865
+ const rootLayout = files[rootLayoutPath];
866
+ if (!rootLayout.includes("FrankieAgents")) {
867
+ const mounted = mountWidget(rootLayout, "FrankieAgents");
868
+ if (!mounted) throw new Error("The root layout has no safe mount point. Add <FrankieAgents /> inside <body> manually.");
869
+ out.set(rootLayoutPath, insertImport(mounted, `import { FrankieAgents } from "./frankie/FrankieAgents";`));
870
+ }
871
+ if (product || legacyChat) {
872
+ out.set(chatModulePath, `"use client";
873
+ import { FrankieChatPage } from "@frankieai/react";
874
+ import "@frankieai/react/styles.css";
875
+ import { ${anonymous ? "" : "getUserToken, "}hostApi } from "./FrankieAgents";
876
+
564
877
  export function WorkspaceChatPage() {
565
878
  const key = process.env.NEXT_PUBLIC_FRANKIE_KEY;
566
879
  if (!key) return <p>Chat is not configured. Set NEXT_PUBLIC_FRANKIE_KEY in your deployment.</p>;
567
- return <FrankieChatPage publishableKey={key}${authProp}${apiProp} />;
880
+ return <FrankieChatPage publishableKey={key}${anonymous ? "" : " getUserToken={getUserToken}"} api={hostApi} />;
568
881
  }
569
- `;
570
- const moduleName = existingMount ? "FrankieSetupChat" : "FrankieWidgetMount";
571
- out.set(integrationAppRoot + "frankie/" + moduleName + ".tsx", mount);
572
- if (!existingMount) {
573
- const layout = files[layoutPath];
574
- const importLine = `import { FrankieWidgetMount } from "./frankie/FrankieWidgetMount";
575
- `;
576
- const withImport = /^["']use client["'];/.test(layout) ? layout.replace(/^(['"]use client['"];)/, "$1\n" + importLine) : importLine + layout;
577
- if (withImport.includes("</body>")) {
578
- out.set(layoutPath, withImport.replace("</body>", " <FrankieWidgetMount />\n</body>"));
579
- } else if (withImport.includes("</main>")) {
580
- out.set(layoutPath, withImport.replace("</main>", " <FrankieWidgetMount />\n</main>"));
581
- } else if (withImport.includes("{children}")) {
582
- out.set(layoutPath, withImport.replace("{children}", "{children}<FrankieWidgetMount />"));
583
- } else {
584
- throw new Error("The selected layout has no safe widget mount point. Add FrankieWidgetMount manually.");
585
- }
882
+ `);
586
883
  }
587
- const pageImport = chatPath === "/chat" ? "../frankie/" : "../../frankie/";
588
- out.set(integrationAppRoot + chatPath.slice(1) + "/page.tsx", `import { WorkspaceChatPage } from "${pageImport}${moduleName}";
884
+ if (product) {
885
+ if (!priorChat && files[integrationAppRoot + chatPath.slice(1) + "/page.tsx"]) throw new Error("Both /chat and /frankie/chat already exist. Choose a chat route manually.");
886
+ const pagePath = integrationAppRoot + chatPath.slice(1) + "/page.tsx";
887
+ out.set(pagePath, `import { WorkspaceChatPage } from "${relativeImport(pagePath, chatModulePath.slice(0, -4))}";
589
888
  export default WorkspaceChatPage;
590
889
  `);
591
- if (!workspace.allowAnonymous && !files[tokenPath]) {
890
+ const { personas: _legacyPersonas, ...kept2 } = manifest;
891
+ out.set(root + "frankie.install.json", JSON.stringify({ ...kept2, workspaceId: workspace.id, chatPath }, null, 2) + "\n");
892
+ if (chatPath !== "/chat") notes.push("Existing /chat preserved. Frankie chat is available at /frankie/chat.");
893
+ }
894
+ if (product && !anonymous && !files[tokenPath]) {
592
895
  const lookup = auth?.lookup ?? `// Frankie could not find your session lookup. Export it from lib/frankie-auth.ts:
593
896
  // export async function getCurrentUser(): Promise<{ id: string; name?: string; email?: string } | null>
594
897
  // then import it here in place of this stub.
@@ -616,8 +919,7 @@ export async function POST(${auth?.handlerParam ?? ""}) {
616
919
  }
617
920
  `);
618
921
  }
619
- if (!files[healthPath]) {
620
- out.set(healthPath, `import { NextResponse } from "next/server";
922
+ out.set(healthPath, `import { NextResponse } from "next/server";
621
923
 
622
924
  export const dynamic = "force-dynamic";
623
925
 
@@ -626,88 +928,128 @@ export async function GET() {
626
928
  {
627
929
  frankie: true,
628
930
  publishableKey: Boolean(process.env.NEXT_PUBLIC_FRANKIE_KEY),
931
+ agents: {
932
+ ${personaIds.map((id) => ` ${JSON.stringify(id)}: Boolean(process.env.${personaEnvKey(id)}),`).join("\n")}
933
+ },
629
934
  jwtSecret: Boolean(process.env.FRANKIE_JWT_SECRET),
630
935
  auth: ${JSON.stringify(authStrategy)},
631
- chatPath: ${JSON.stringify(chatPath)},
632
936
  },
633
937
  { headers: { "cache-control": "no-store", "access-control-allow-origin": "*" } },
634
938
  );
635
939
  }
636
940
  `);
637
- }
638
- let doc;
639
- for (const [path, content] of Object.entries(files)) {
640
- if (!/(?:openapi|swagger)[^/]*\.json$/i.test(path)) continue;
641
- try {
642
- const parsed = JSON.parse(content);
643
- if (parsed.openapi && parsed.paths) {
644
- doc = parsed;
645
- break;
941
+ let operationCount = 0;
942
+ const specPath = root + "public/frankie.openapi.json";
943
+ if (product) {
944
+ let doc;
945
+ for (const [path, content] of Object.entries(files)) {
946
+ if (!/(?:openapi|swagger)[^/]*\.json$/i.test(path)) continue;
947
+ try {
948
+ const parsed = JSON.parse(content);
949
+ if (parsed.openapi && parsed.paths) {
950
+ doc = parsed;
951
+ break;
952
+ }
953
+ } catch {
646
954
  }
647
- } catch {
648
955
  }
649
- }
650
- const middlewareMatchers = nextMiddlewareMatchers(files, sourceRoot, root);
651
- const paths = {};
652
- let withheld = 0;
653
- for (const [file, content] of Object.entries(files)) {
654
- if (/\.(test|spec)\.[tj]sx?$/.test(file)) continue;
655
- const nextRoute = file.match(/(?:^|\/)app(\/.*)\/route\.[tj]s$/);
656
- if (nextRoute) {
657
- const path = nextRoute[1].replace(/\/\([^/]+\)/g, "").replace(/\[([^\]]+)\]/g, "{$1}");
658
- if (path.includes("...") || /\/(?:auth|frankie)(?:\/|$)/.test(path)) continue;
659
- const guarded = matchesAnyMatcher(middlewareMatchers, path) || IN_HANDLER_AUTH_GUARD.test(content);
660
- for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
661
- if (new RegExp("export\\s+(?:(?:async\\s+)?function\\s+" + method + "\\b|(?:const|let)\\s+" + method + "\\s*=)").test(content)) {
662
- if (!addInstallOperation(paths, method, path, file, guarded)) withheld += 1;
956
+ const middlewareMatchers = nextMiddlewareMatchers(files, sourceRoot, root);
957
+ const paths = {};
958
+ let withheld = 0;
959
+ for (const [file, content] of Object.entries(files)) {
960
+ if (/\.(test|spec)\.[tj]sx?$/.test(file)) continue;
961
+ const nextRoute = file.match(/(?:^|\/)app(\/.*)\/route\.[tj]s$/);
962
+ if (nextRoute) {
963
+ const path = nextRoute[1].replace(/\/\([^/]+\)/g, "").replace(/\[([^\]]+)\]/g, "{$1}");
964
+ if (path.includes("...") || /\/(?:auth|frankie)(?:\/|$)/.test(path)) continue;
965
+ const guarded = matchesAnyMatcher(middlewareMatchers, path) || IN_HANDLER_AUTH_GUARD.test(content);
966
+ for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
967
+ if (new RegExp("export\\s+(?:(?:async\\s+)?function\\s+" + method + "\\b|(?:const|let)\\s+" + method + "\\s*=)").test(content)) {
968
+ if (!addInstallOperation(paths, method, path, file, guarded)) withheld += 1;
969
+ }
663
970
  }
971
+ } else if (/\.[tj]s$/.test(file) && /(?:routes?|api)\//.test(file)) {
972
+ for (const match of content.matchAll(/\b(?:app|router|fastify)\.(get|post|put|patch|delete)(?:\s*<[^;]{1,1600}?>)?\s*\(\s*["']([^"']+)["'](\s*,\s*[A-Za-z_$][\w$]*\s*,)?/g))
973
+ if (!addInstallOperation(paths, match[1].toUpperCase(), match[2].replace(/:([A-Za-z0-9_]+)/g, "{$1}"), file, Boolean(match[3]))) withheld += 1;
664
974
  }
665
- } else if (/\.[tj]s$/.test(file) && /(?:routes?|api)\//.test(file)) {
666
- for (const match of content.matchAll(/\b(?:app|router|fastify)\.(get|post|put|patch|delete)(?:\s*<[^;]{1,1600}?>)?\s*\(\s*["']([^"']+)["'](\s*,\s*[A-Za-z_$][\w$]*\s*,)?/g))
667
- if (!addInstallOperation(paths, match[1].toUpperCase(), match[2].replace(/:([A-Za-z0-9_]+)/g, "{$1}"), file, Boolean(match[3]))) withheld += 1;
668
975
  }
976
+ doc ??= { openapi: "3.0.3", info: { title: workspace.name + " API", version: "1.0.0", description: "Generated route inventory. Review schemas, authorization, and the deployed origin before enabling tools." }, paths };
977
+ for (const [path, item] of Object.entries(paths)) doc.paths[path] = { ...item, ...doc.paths[path] };
978
+ operationCount = Object.values(doc.paths).reduce((n, item) => n + Object.keys(item).filter((m) => /^(get|post|put|patch|delete)$/.test(m)).length, 0);
979
+ out.set(specPath, JSON.stringify(doc, null, 2) + "\n");
980
+ if (!operationCount) notes.push("No supported API routes detected. The OpenAPI draft has no operations; add the real schema before connecting tools.");
981
+ if (withheld) notes.push(`Withheld ${withheld} newly discovered write operation(s) with no detected authorization guard (no Express/Fastify middleware argument, Next.js middleware.ts matcher covering the route, or in-handler auth check). Add them to the spec yourself once they're protected.`);
982
+ notes.push("Generated OpenAPI includes read and write routes. Review schemas before approving tools; new connections still default to GET-only access.");
983
+ notes.push("No public spec endpoint is required: Frankie connects this committed spec on install and reads later changes from your repository through the GitHub connection.");
984
+ notes.push("The token endpoint issues a signed user JWT, but your product API must validate it and enforce user/resource permissions before exposing tools.");
985
+ } else {
986
+ const envKey = personaEnvKey(input.persona.id);
987
+ notes.push(`${plainText(input.persona.label, 60)} turns on when ${envKey} is set in each frontend environment. Its pages come from Frankie at runtime, so changing them later needs no new pull request.`);
669
988
  }
670
- doc ??= { openapi: "3.0.3", info: { title: workspace.name + " API", version: "1.0.0", description: "Generated route inventory. Review schemas, authorization, and the deployed origin before enabling tools." }, paths };
671
- for (const [path, item] of Object.entries(paths)) doc.paths[path] = { ...item, ...doc.paths[path] };
672
- const specPath = root + "public/frankie.openapi.json";
673
- const operationCount = Object.values(doc.paths).reduce((n, item) => n + Object.keys(item).filter((m) => /^(get|post|put|patch|delete)$/.test(m)).length, 0);
674
- out.set(specPath, JSON.stringify(doc, null, 2) + "\n");
675
- if (!operationCount) notes.push("No supported API routes detected. The OpenAPI draft has no operations; add the real schema before connecting tools.");
676
- if (withheld) notes.push(`Withheld ${withheld} newly discovered write operation(s) with no detected authorization guard (no Express/Fastify middleware argument, Next.js middleware.ts matcher covering the route, or in-handler auth check). Add them to the spec yourself once they're protected.`);
677
- notes.push("Generated OpenAPI includes read and write routes. Review schemas before approving tools; new connections still default to GET-only access.");
678
- notes.push("No public spec endpoint is required: Frankie connects this committed spec on install and reads later changes from your repository through the GitHub connection.");
679
- notes.push("The token endpoint issues a signed user JWT, but your product API must validate it and enforce user/resource permissions before exposing tools.");
680
989
  const envPath = root + ".env.example";
681
- const env = (files[envPath] ?? "").replace(/^NEXT_PUBLIC_FRANKIE_KEY=.*\n?/gm, "").replace(/^FRANKIE_JWT_SECRET=.*\n?/gm, "").trimEnd();
682
- out.set(envPath, env + "\n# Public at runtime, but inject it through your deployment environment instead of committing a workspace value.\nNEXT_PUBLIC_FRANKIE_KEY=\n" + (workspace.allowAnonymous ? "" : "# Server only: use the secret from this workspace. Never commit its value.\nFRANKIE_JWT_SECRET=\n"));
683
- out.set(root + "FRANKIE_SETUP.md", `# Frankie setup
990
+ const keyLine = /^(?:NEXT_PUBLIC_FRANKIE_KEY(?:_[A-Z0-9_]+)?|FRANKIE_JWT_SECRET)=.*$/;
991
+ const kept = [];
992
+ let inBlock = false;
993
+ for (const line of (files[envPath] ?? "").split("\n")) {
994
+ if (line === ENV_BLOCK_START) {
995
+ inBlock = true;
996
+ continue;
997
+ }
998
+ if (line === ENV_BLOCK_END) {
999
+ inBlock = false;
1000
+ continue;
1001
+ }
1002
+ if (inBlock || keyLine.test(line) || LEGACY_ENV_COMMENTS.includes(line)) continue;
1003
+ kept.push(line);
1004
+ }
1005
+ const before = kept.join("\n").trimEnd();
1006
+ const block = [
1007
+ ENV_BLOCK_START,
1008
+ "# Publishable keys are public at runtime, but set them in your deployment environment instead of committing values. An agent stays off until its key is set.",
1009
+ "NEXT_PUBLIC_FRANKIE_KEY=",
1010
+ ...personaIds.map((id) => personaEnvKey(id) + "="),
1011
+ ...anonymous ? [] : ["# Server only: the secret from your Frankie workspace. Never commit its value or expose it to the browser.", "FRANKIE_JWT_SECRET="],
1012
+ ENV_BLOCK_END
1013
+ ].join("\n");
1014
+ out.set(envPath, (before ? before + "\n\n" : "") + block + "\n");
1015
+ if (product) {
1016
+ const keys = ["NEXT_PUBLIC_FRANKIE_KEY", ...personaIds.map(personaEnvKey)];
1017
+ out.set(root + "FRANKIE_SETUP.md", `# Frankie setup
684
1018
 
685
- Workspace: ${workspace.name}
1019
+ Workspace: ${plainText(workspace.name)}
686
1020
 
687
- 1. Copy NEXT_PUBLIC_FRANKIE_KEY from Frankie \u2192 Workspace \u2192 Developer details into the frontend deployment environment. Frankie deliberately leaves the value blank in this PR.
688
- ${workspace.allowAnonymous ? "" : "2. Add FRANKIE_JWT_SECRET from this workspace to the server hosting /api/frankie/token. Never commit the value.\n"}3. Run your package manager and commit the updated lockfile (for pnpm: pnpm install --lockfile-only). The remote GitHub installer cannot safely synthesize a lockfile.
689
- 4. Nothing to expose: this PR commits your spec to the repository, Frankie connects it for you on install, and later spec changes are read from your repository through the GitHub connection \u2014 no public spec endpoint or middleware bypass is required.
1021
+ 1. Copy NEXT_PUBLIC_FRANKIE_KEY from Frankie \u2192 Workspace \u2192 Developer details into every frontend deployment environment (Preview and Production). Frankie deliberately leaves the value blank in this PR.
1022
+ ${anonymous ? "" : "2. Add FRANKIE_JWT_SECRET from this workspace to the server hosting /api/frankie/token only. Never commit the value or expose it to the browser.\n"}3. Run your package manager and commit the updated lockfile (for pnpm: pnpm install --lockfile-only). The remote GitHub installer cannot safely synthesize a lockfile.
1023
+ 4. Nothing to expose: this PR commits your spec to the repository, Frankie connects it for you on install, and later spec changes are read from your repository through the GitHub connection.
690
1024
  5. Open ${chatPath}. It is created inside the detected authenticated dashboard group when one exists; add its link to the host sidebar during review.
691
- 6. In Frankie \u2192 Tools & MCP, approve the operations you want and choose access limits (the spec is already connected from this PR; it includes read and write routes). After merging later spec changes, use GitHub integration \u2192 Refresh Frankie from merged spec.
1025
+ 6. In Frankie \u2192 Tools & MCP, approve the operations you want and choose access limits. After merging later spec changes, use GitHub integration \u2192 Refresh Frankie from merged spec.
692
1026
  7. Forwarding sends the Frankie-signed JWT. Your API must validate it and enforce user/resource permissions. It is not automatically interchangeable with a Firebase or OAuth access token.
693
- 8. Add NEXT_PUBLIC_FRANKIE_KEY to every frontend deployment environment (Preview and Production). ${workspace.allowAnonymous ? "" : "Add FRANKIE_JWT_SECRET only to the server environment running /api/frankie/token; never expose it to the browser.\n"}9. Review conversations in Workspace \u2192 Chat sessions.
1027
+
1028
+ ## More agents
1029
+
1030
+ Every Frankie agent mounts from \`frankie/FrankieAgents.tsx\` in the root layout. To turn on another agent (Support, Docs), set its key in the deployment \u2014 no code change:
1031
+
1032
+ ${keys.map((key) => "- `" + key + "`").join("\n")}
1033
+
1034
+ Each agent shows on the pages chosen for it in Frankie; those are read at runtime.
694
1035
 
695
1036
  ## Remaining setup
696
1037
 
697
1038
  ${notes.map((n) => "- " + n).join("\n")}
698
1039
  `);
699
- const planned = [...out].map(([path, content]) => ({ path, content }));
1040
+ }
1041
+ const planned = [...out].filter(([path, content]) => files[path] !== content).map(([path, content]) => ({ path, content }));
700
1042
  assertNoCommittedFrankieKeys(planned);
701
- return { files: planned, root, chatPath, specPath, operationCount, needsAuthSetup, authStrategy, notes };
1043
+ return { files: planned, deletions, root, chatPath, specPath, operationCount, needsAuthSetup, authStrategy, notes };
702
1044
  }
703
1045
  var GET_CURRENT_USER = /export\s+(?:async\s+function\s+getCurrentUser\b|(?:const|let)\s+getCurrentUser\b|\{[^}]*\bgetCurrentUser\b[^}]*\})/;
704
1046
  var CUSTOM_AUTH_MODULES = ["lib/frankie-auth", "frankie/auth", "lib/auth", "lib/session", "lib/current-user", "lib/user", "server/auth", "auth"];
705
- function detectAuth(files, sourceRoot, deps) {
1047
+ function detectAuth(files, sourceRoot, deps, ref) {
706
1048
  for (const module of CUSTOM_AUTH_MODULES) {
707
1049
  for (const ext of [".ts", ".tsx", ".js"]) {
708
1050
  const source = files[sourceRoot + module + ext];
709
1051
  if (source && GET_CURRENT_USER.test(source)) {
710
- return { strategy: "custom", lookup: `import { getCurrentUser } from "@/${module}";
1052
+ return { strategy: "custom", lookup: `import { getCurrentUser } from "${ref(module)}";
711
1053
  ` };
712
1054
  }
713
1055
  }
@@ -716,8 +1058,8 @@ function detectAuth(files, sourceRoot, deps) {
716
1058
  return {
717
1059
  strategy: "firebase",
718
1060
  lookup: `import { cookies } from "next/headers";
719
- import { getAdminAuth } from "@/lib/firebase-admin";
720
- import { SESSION_COOKIE } from "@/lib/session";
1061
+ import { getAdminAuth } from "${ref("lib/firebase-admin")}";
1062
+ import { SESSION_COOKIE } from "${ref("lib/session")}";
721
1063
  async function getCurrentUser() {
722
1064
  const cookie = (await cookies()).get(SESSION_COOKIE)?.value;
723
1065
  if (!cookie) return null;
@@ -731,7 +1073,7 @@ async function getCurrentUser() {
731
1073
  if (authFile?.includes("NextAuth") && /export\s+(?:const|let)\s*\{[^}]*\bauth\b/.test(authFile)) {
732
1074
  return {
733
1075
  strategy: "nextauth",
734
- lookup: `import { auth } from "@/auth";
1076
+ lookup: `import { auth } from "${ref("auth")}";
735
1077
  async function getCurrentUser() {
736
1078
  const session = await auth();
737
1079
  const user = session?.user as { id?: string; name?: string | null; email?: string | null } | undefined;
@@ -809,6 +1151,107 @@ function matchesAnyMatcher(matchers, path) {
809
1151
  return new RegExp(`^${regexBody}$`).test(path);
810
1152
  });
811
1153
  }
1154
+ function insertImport(source, importLine) {
1155
+ const skip = [/^\s+/, /^\/\/[^\n]*/, /^\/\*[\s\S]*?\*\//];
1156
+ const keep = [/^["'][^"'\n]*["'][ \t]*;?/, /^import\s+(?:type\s+)?(?:[^"'`;]*?\sfrom\s*)?["'][^"'\n]+["'][ \t]*;?/];
1157
+ let at = 0;
1158
+ let afterPrologue = 0;
1159
+ let afterComments = 0;
1160
+ for (; ; ) {
1161
+ const rest = source.slice(at);
1162
+ const skipped = skip.map((pattern) => rest.match(pattern)?.[0]).find((hit) => hit);
1163
+ if (skipped) {
1164
+ at += skipped.length;
1165
+ if (!/^\s+$/.test(skipped) && afterPrologue === 0) afterComments = at;
1166
+ continue;
1167
+ }
1168
+ const kept = keep.map((pattern) => rest.match(pattern)?.[0]).find((hit) => hit);
1169
+ if (!kept) break;
1170
+ at += kept.length;
1171
+ afterPrologue = at;
1172
+ }
1173
+ const end = afterPrologue || afterComments;
1174
+ if (end === 0) return `${importLine}
1175
+
1176
+ ${source}`;
1177
+ return `${source.slice(0, end)}
1178
+ ${importLine}${source.slice(end)}`;
1179
+ }
1180
+ function mountWidget(layout, component = "FrankieWidgetMount") {
1181
+ const mount = `<${component} />`;
1182
+ for (const tag of ["</body>", "</main>"]) {
1183
+ const at = layout.indexOf(tag);
1184
+ if (at === -1) continue;
1185
+ const lineStart = layout.lastIndexOf("\n", at) + 1;
1186
+ const lead = layout.slice(lineStart, at);
1187
+ if (!/^[ \t]*$/.test(lead)) return `${layout.slice(0, at)}${mount}${layout.slice(at)}`;
1188
+ const unit = lead.includes(" ") ? " " : " ";
1189
+ return `${layout.slice(0, lineStart)}${lead}${unit}${mount}
1190
+ ${layout.slice(lineStart)}`;
1191
+ }
1192
+ return layout.includes("{children}") ? layout.replace("{children}", `{children}${mount}`) : void 0;
1193
+ }
1194
+ function withDependencies(current, added) {
1195
+ const existing = current ?? {};
1196
+ const merged = { ...existing };
1197
+ for (const [name, range] of Object.entries(added)) merged[name] ??= range;
1198
+ const names = Object.keys(existing);
1199
+ const sorted = [...names].sort((a, b) => a.localeCompare(b, "en"));
1200
+ if (names.some((name, index) => name !== sorted[index])) return merged;
1201
+ return Object.fromEntries(Object.keys(merged).sort((a, b) => a.localeCompare(b, "en")).map((name) => [name, merged[name]]));
1202
+ }
1203
+ function renderJson(value, original) {
1204
+ const indent = original?.match(/^([ \t]+)"/m)?.[1] ?? " ";
1205
+ const text = JSON.stringify(value, null, indent);
1206
+ return original === void 0 || original.endsWith("\n") ? `${text}
1207
+ ` : text;
1208
+ }
1209
+ function moduleImporter(files, root, sourceRoot) {
1210
+ const tsconfig = files[root + "tsconfig.json"];
1211
+ if (tsconfig === void 0) return (_from, module) => `@/${module}`;
1212
+ const alias = aliasFor(tsconfig, root, sourceRoot);
1213
+ if (alias !== void 0) return (_from, module) => `${alias}${module}`;
1214
+ return (from, module) => relativeImport(from, sourceRoot + module);
1215
+ }
1216
+ function aliasFor(tsconfig, root, sourceRoot) {
1217
+ let options;
1218
+ try {
1219
+ const parsed = JSON.parse(tsconfig.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1"));
1220
+ options = parsed.compilerOptions;
1221
+ } catch {
1222
+ return void 0;
1223
+ }
1224
+ const paths = options?.paths;
1225
+ if (!paths || typeof paths !== "object") return void 0;
1226
+ const baseUrl = typeof options?.baseUrl === "string" ? options.baseUrl : ".";
1227
+ const target = normalizePath(sourceRoot);
1228
+ for (const [pattern, targets] of Object.entries(paths)) {
1229
+ if (!pattern.endsWith("/*") || !Array.isArray(targets)) continue;
1230
+ for (const entry of targets) {
1231
+ if (typeof entry !== "string" || !entry.endsWith("/*")) continue;
1232
+ if (normalizePath(`${root}${baseUrl}/${entry.slice(0, -1)}`) === target) return pattern.slice(0, -1);
1233
+ }
1234
+ }
1235
+ return void 0;
1236
+ }
1237
+ function normalizePath(path) {
1238
+ const out = [];
1239
+ for (const part of path.split("/")) {
1240
+ if (part === "" || part === ".") continue;
1241
+ if (part === "..") out.pop();
1242
+ else out.push(part);
1243
+ }
1244
+ return out.join("/") + "/";
1245
+ }
1246
+ function relativeImport(fromFile, module) {
1247
+ const from = normalizePath(fromFile.slice(0, fromFile.lastIndexOf("/") + 1)).split("/").filter(Boolean);
1248
+ const to = module.split("/").filter((part) => part !== "" && part !== ".");
1249
+ let common = 0;
1250
+ while (common < from.length && common < to.length && from[common] === to[common]) common += 1;
1251
+ const up = from.length - common;
1252
+ const rel = [...Array(up).fill(".."), ...to.slice(common)].join("/");
1253
+ return up === 0 ? `./${rel}` : rel;
1254
+ }
812
1255
 
813
1256
  // src/sdk-updates.ts
814
1257
  function sdkUpdateWorkflow(mode) {
@@ -1048,8 +1491,146 @@ function compareApiDocuments(before, after) {
1048
1491
  unchangedIds: [...next].filter(([key, value]) => old.get(key)?.fingerprint === value.fingerprint).map(([, value]) => value.id)
1049
1492
  };
1050
1493
  }
1494
+
1495
+ // src/theme-infer.ts
1496
+ var hex2 = (value) => Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0");
1497
+ var toHex = (r, g, b) => "#" + hex2(r) + hex2(g) + hex2(b);
1498
+ function hslToHex(h, s, l) {
1499
+ const sat = s / 100, light = l / 100;
1500
+ const k = (n) => (n + h / 30) % 12;
1501
+ const a = sat * Math.min(light, 1 - light);
1502
+ const f = (n) => light - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
1503
+ return toHex(f(0) * 255, f(8) * 255, f(4) * 255);
1504
+ }
1505
+ function oklchToHex(l, c, hDeg) {
1506
+ const h = hDeg * Math.PI / 180;
1507
+ const a = c * Math.cos(h), b = c * Math.sin(h);
1508
+ const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
1509
+ const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
1510
+ const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
1511
+ const linear = [
1512
+ 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
1513
+ -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
1514
+ -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_
1515
+ ];
1516
+ const gamma = (x) => (x <= 31308e-7 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055) * 255;
1517
+ return toHex(gamma(linear[0]), gamma(linear[1]), gamma(linear[2]));
1518
+ }
1519
+ var NUMBER = String.raw`-?\d*\.?\d+`;
1520
+ var parts = (body) => body.replace(/\/.*$/, "").split(/[\s,]+/).filter(Boolean);
1521
+ function normalizeColor(raw) {
1522
+ const value = raw.trim().replace(/\s*!important$/, "").toLowerCase();
1523
+ let match = value.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
1524
+ if (match) {
1525
+ const digits = match[1];
1526
+ return digits.length <= 4 ? "#" + [...digits.slice(0, 3)].map((d) => d + d).join("") : "#" + digits.slice(0, 6);
1527
+ }
1528
+ match = value.match(/^rgba?\(([^)]*)\)$/);
1529
+ if (match) {
1530
+ const [r, g, b] = parts(match[1]).map((part) => part.endsWith("%") ? parseFloat(part) * 2.55 : parseFloat(part));
1531
+ return [r, g, b].every((n) => Number.isFinite(n)) ? toHex(r, g, b) : void 0;
1532
+ }
1533
+ match = value.match(/^hsla?\(([^)]*)\)$/) ?? value.match(new RegExp(`^(${NUMBER}(?:deg)?\\s+${NUMBER}%\\s+${NUMBER}%)$`));
1534
+ if (match) {
1535
+ const [h, s, l] = parts(match[1]).map((part) => parseFloat(part));
1536
+ return [h, s, l].every((n) => Number.isFinite(n)) ? hslToHex(h, s, l) : void 0;
1537
+ }
1538
+ match = value.match(/^oklch\(([^)]*)\)$/);
1539
+ if (match) {
1540
+ const [lRaw, cRaw, hRaw] = parts(match[1]);
1541
+ const l = lRaw?.endsWith("%") ? parseFloat(lRaw) / 100 : parseFloat(lRaw ?? "");
1542
+ const c = cRaw?.endsWith("%") ? parseFloat(cRaw) / 100 * 0.4 : parseFloat(cRaw ?? "");
1543
+ const h = hRaw === void 0 || hRaw === "none" ? 0 : parseFloat(hRaw);
1544
+ return [l, c, h].every((n) => Number.isFinite(n)) ? oklchToHex(l, c, h) : void 0;
1545
+ }
1546
+ return void 0;
1547
+ }
1548
+ function rootVariables(css) {
1549
+ const vars = /* @__PURE__ */ new Map();
1550
+ const source = css.replace(/\/\*[\s\S]*?\*\//g, "");
1551
+ const blocks = /([^{}]*)\{([^{}]*)\}/g;
1552
+ for (let block = blocks.exec(source); block; block = blocks.exec(source)) {
1553
+ const selector = block[1].split(";").pop().trim();
1554
+ if (!/(?:^|[\s,]):root\b(?![^,]*\.dark)|^@theme\b/.test(selector) || /\.dark|\[data-theme/.test(selector)) continue;
1555
+ for (const declaration of block[2].split(";")) {
1556
+ const found = declaration.match(/^\s*(--[\w-]+)\s*:\s*([\s\S]+?)\s*$/);
1557
+ if (found && !vars.has(found[1])) vars.set(found[1], found[2]);
1558
+ }
1559
+ }
1560
+ return vars;
1561
+ }
1562
+ function resolve(value, vars, depth = 0) {
1563
+ if (depth > 4) return void 0;
1564
+ const reference = value.match(/^(?:(hsla?|oklch|rgba?)\(\s*)?var\(\s*(--[\w-]+)\s*(?:,[^)]*)?\)\s*\)?$/i);
1565
+ if (!reference) return normalizeColor(value);
1566
+ const inner = vars.get(reference[2]);
1567
+ if (!inner) return void 0;
1568
+ const wrapper = reference[1]?.toLowerCase();
1569
+ return wrapper && !/^\s*(?:#|[a-z]+\()/i.test(inner) ? normalizeColor(wrapper + "(" + inner + ")") : resolve(inner, vars, depth + 1);
1570
+ }
1571
+ function radiusOption(value) {
1572
+ const match = value.trim().match(/^(\d*\.?\d+)(rem|px)?$/);
1573
+ if (!match) return void 0;
1574
+ const rem = match[2] === "px" ? parseFloat(match[1]) / 16 : parseFloat(match[1]);
1575
+ return rem < 0.525 ? "0.35" : rem < 0.9 ? "0.7" : "1.1";
1576
+ }
1577
+ var CSS_PRIORITY = [/(?:^|\/)app\/globals\.css$/, /(?:^|\/)styles\/globals\.css$/, /globals\.css$/, /\.css$/];
1578
+ var PRIMARY_VARS = ["--primary", "--color-primary", "--brand", "--color-brand", "--accent", "--color-accent"];
1579
+ function rank(path) {
1580
+ const index = CSS_PRIORITY.findIndex((pattern) => pattern.test(path));
1581
+ return index === -1 ? CSS_PRIORITY.length : index;
1582
+ }
1583
+ function inferTheme(files, root = "") {
1584
+ const inRoot = (path) => path.startsWith(root) && !/(?:^|\/)(?:node_modules|dist|\.next|vendor|coverage)\//.test(path);
1585
+ const theme = {};
1586
+ const sources = [];
1587
+ const set = (field, value, path) => {
1588
+ if (!value || theme[field]) return;
1589
+ theme[field] = value;
1590
+ sources.push({ field, path });
1591
+ };
1592
+ const cssFiles = Object.keys(files).filter((path) => inRoot(path) && path.endsWith(".css")).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
1593
+ const vars = /* @__PURE__ */ new Map();
1594
+ const varPath = /* @__PURE__ */ new Map();
1595
+ for (const path of cssFiles) {
1596
+ for (const [name, value] of rootVariables(files[path])) if (!vars.has(name)) {
1597
+ vars.set(name, value);
1598
+ varPath.set(name, path);
1599
+ }
1600
+ }
1601
+ for (const name of PRIMARY_VARS) if (vars.has(name)) set("primary", resolve(vars.get(name), vars), varPath.get(name));
1602
+ for (const name of ["--background", "--color-background"]) if (vars.has(name)) set("background", resolve(vars.get(name), vars), varPath.get(name));
1603
+ for (const name of ["--foreground", "--color-foreground"]) if (vars.has(name)) set("foreground", resolve(vars.get(name), vars), varPath.get(name));
1604
+ if (vars.has("--radius")) set("radius", radiusOption(vars.get("--radius")), varPath.get("--radius"));
1605
+ for (const path of Object.keys(files).filter((p) => inRoot(p) && /(?:^|\/)tailwind\.config\.[cm]?[jt]s$/.test(p)).sort()) {
1606
+ const source = files[path];
1607
+ for (const key of ["primary", "brand", "accent"]) {
1608
+ const direct = source.match(new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`));
1609
+ const nested = source.match(new RegExp(`["']?${key}["']?\\s*:\\s*\\{[^}]*?DEFAULT\\s*:\\s*["'\`]([^"'\`]+)["'\`]`));
1610
+ const raw = direct?.[1] ?? nested?.[1];
1611
+ if (raw) set("primary", resolve(raw, vars), path);
1612
+ }
1613
+ }
1614
+ for (const path of Object.keys(files).filter((p) => inRoot(p) && /(?:^|\/)app\/(?:\([^/]+\)\/)?layout\.tsx$/.test(p)).sort()) {
1615
+ const raw = files[path].match(/themeColor\s*:\s*(?:\[\s*\{[^}]*?color\s*:\s*)?["'`]([^"'`]+)["'`]/)?.[1];
1616
+ if (raw) set("primary", normalizeColor(raw), path);
1617
+ }
1618
+ for (const path of Object.keys(files).filter((p) => inRoot(p) && /(?:^|\/)(?:manifest\.json|site\.webmanifest|manifest\.webmanifest)$/.test(p)).sort()) {
1619
+ try {
1620
+ const manifest = JSON.parse(files[path]);
1621
+ if (typeof manifest.theme_color === "string") set("primary", normalizeColor(manifest.theme_color), path);
1622
+ if (typeof manifest.background_color === "string") set("background", normalizeColor(manifest.background_color), path);
1623
+ } catch {
1624
+ }
1625
+ }
1626
+ if (!theme.primary) return void 0;
1627
+ return { theme: { ...theme, primary: theme.primary }, sources };
1628
+ }
1051
1629
  export {
1052
1630
  FIREWORKS_MODELS,
1631
+ SKILL_TOOL_NAME,
1632
+ STANDARD_AGENT_IDS,
1633
+ STARTER_PROMPT_ICONS,
1053
1634
  activeSkills,
1054
1635
  agentConfigSchema,
1055
1636
  apiCapabilitySchema,
@@ -1068,14 +1649,32 @@ export {
1068
1649
  evaluateToolPolicy,
1069
1650
  featuresConfigSchema,
1070
1651
  frankieConfigSchema,
1652
+ inferTheme,
1653
+ insertImport,
1654
+ installAgentIds,
1655
+ installBranch,
1656
+ installTitle,
1657
+ knowledgeBaseSchema,
1658
+ knowledgeSourceSchema,
1071
1659
  limitsConfigSchema,
1072
1660
  matchPath,
1661
+ matchesAnyPagePattern,
1662
+ matchesInstallBranch,
1663
+ matchesPagePattern,
1073
1664
  mcpServerSchema,
1074
1665
  modelChoiceSchema,
1075
1666
  modelConfigSchema,
1667
+ moduleImporter,
1668
+ mountWidget,
1669
+ normalizeColor,
1076
1670
  operationId,
1077
1671
  parseFrankieConfig,
1672
+ personaEnvKey,
1673
+ personaProfileSchema,
1674
+ plainText,
1078
1675
  planHostedInstall,
1676
+ renderJson,
1677
+ resolvePersonaConfig,
1079
1678
  sdkUpdateWorkflow,
1080
1679
  skillSchema,
1081
1680
  starterPromptSchema,
@@ -1084,6 +1683,7 @@ export {
1084
1683
  toolDescriptions,
1085
1684
  toolPolicySchema,
1086
1685
  validateApiDocument,
1087
- widgetThemeSchema
1686
+ widgetThemeSchema,
1687
+ withDependencies
1088
1688
  };
1089
1689
  //# sourceMappingURL=index.js.map