@frankieai/core 0.2.6 → 0.2.8

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.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ API_SYNC_WORKFLOW_PATH: () => API_SYNC_WORKFLOW_PATH,
23
24
  FIREWORKS_MODELS: () => FIREWORKS_MODELS,
24
25
  SKILL_TOOL_NAME: () => SKILL_TOOL_NAME,
25
26
  STANDARD_AGENT_IDS: () => STANDARD_AGENT_IDS,
@@ -30,12 +31,15 @@ __export(index_exports, {
30
31
  apiMethods: () => apiMethods,
31
32
  apiOperations: () => apiOperations,
32
33
  apiSourceSchema: () => apiSourceSchema,
34
+ apiSyncWorkflow: () => apiSyncWorkflow,
33
35
  approvedSkillTools: () => approvedSkillTools,
34
36
  assertNoCommittedFrankieKeys: () => assertNoCommittedFrankieKeys,
35
37
  authConfigSchema: () => authConfigSchema,
36
38
  brandingConfigSchema: () => brandingConfigSchema,
37
39
  canonicalJson: () => canonicalJson,
38
40
  compareApiDocuments: () => compareApiDocuments,
41
+ conversationLog: () => conversationLog,
42
+ conversationLogFileName: () => conversationLogFileName,
39
43
  defineFrankieConfig: () => defineFrankieConfig,
40
44
  describeTool: () => describeTool,
41
45
  draftSkill: () => draftSkill,
@@ -46,7 +50,9 @@ __export(index_exports, {
46
50
  insertImport: () => insertImport,
47
51
  installAgentIds: () => installAgentIds,
48
52
  installBranch: () => installBranch,
53
+ installPrompt: () => installPrompt,
49
54
  installTitle: () => installTitle,
55
+ isSafeRepositoryPath: () => isSafeRepositoryPath,
50
56
  knowledgeBaseSchema: () => knowledgeBaseSchema,
51
57
  knowledgeSourceSchema: () => knowledgeSourceSchema,
52
58
  limitsConfigSchema: () => limitsConfigSchema,
@@ -63,6 +69,7 @@ __export(index_exports, {
63
69
  operationId: () => operationId,
64
70
  parseFrankieConfig: () => parseFrankieConfig,
65
71
  personaEnvKey: () => personaEnvKey,
72
+ personaInstructions: () => personaInstructions,
66
73
  personaProfileSchema: () => personaProfileSchema,
67
74
  plainText: () => plainText,
68
75
  planHostedInstall: () => planHostedInstall,
@@ -75,6 +82,7 @@ __export(index_exports, {
75
82
  thinkingLevelSchema: () => thinkingLevelSchema,
76
83
  toolDescriptions: () => toolDescriptions,
77
84
  toolPolicySchema: () => toolPolicySchema,
85
+ userLabel: () => userLabel,
78
86
  validateApiDocument: () => validateApiDocument,
79
87
  widgetThemeSchema: () => widgetThemeSchema,
80
88
  withDependencies: () => withDependencies
@@ -532,7 +540,14 @@ var agentConfigSchema = import_zod2.z.object({
532
540
  maxBudgetUsd: import_zod2.z.number().positive().optional(),
533
541
  tools: toolPolicySchema.prefault({}),
534
542
  /** Show tool activity in the transcript. */
535
- showToolActivity: import_zod2.z.boolean().default(true)
543
+ showToolActivity: import_zod2.z.boolean().default(true),
544
+ /**
545
+ * Ask the user before any action that changes data runs. The chat shows the
546
+ * action with Approve and Cancel; it runs only on Approve, with exactly the
547
+ * inputs shown. Reads never ask. Applies to chats from an SDK that can show
548
+ * the card; older SDK versions and headless callers run as before.
549
+ */
550
+ confirmWrites: import_zod2.z.boolean().default(true)
536
551
  });
537
552
  var authConfigSchema = import_zod2.z.object({
538
553
  /**
@@ -612,6 +627,18 @@ var personaProfileSchema = import_zod2.z.object({
612
627
  * sign in.
613
628
  */
614
629
  allowAnonymous: import_zod2.z.boolean().optional(),
630
+ /**
631
+ * What this agent helps with, in the team's own words ("pricing, plans,
632
+ * getting started"). The one thing a team needs to write; the agent's role,
633
+ * greeting and instructions come from defaults. It is added to the agent's
634
+ * instructions at runtime (see `personaInstructions`).
635
+ */
636
+ scope: import_zod2.z.string().trim().max(2e3).optional(),
637
+ /**
638
+ * Where to send people when the agent can't answer and it doesn't hand the
639
+ * chat to the team itself: a contact page (https) or an email (mailto:).
640
+ */
641
+ contactUrl: import_zod2.z.string().trim().max(500).regex(/^(?:https:\/\/[^\s]+|mailto:[^\s@]+@[^\s@]+)$/, "Use an https:// link or a mailto: address.").optional(),
615
642
  /**
616
643
  * Shallow-merged over the workspace branding. Each field is optional with no
617
644
  * default, so only the keys the persona sets override the workspace's — an
@@ -652,7 +679,13 @@ var frankieConfigSchema = import_zod2.z.object({
652
679
  * is the primary persona. Each entry resolves to its own one-agent config via
653
680
  * its own publishable key, so the data plane still sees one config per key.
654
681
  */
655
- personas: import_zod2.z.array(personaProfileSchema).max(8).default([])
682
+ personas: import_zod2.z.array(personaProfileSchema).max(8).default([]),
683
+ /**
684
+ * Which agent a *resolved* config is: absent for the primary (product)
685
+ * agent, the persona's id (support, docs, …) otherwise. Set only by
686
+ * resolvePersonaConfig at request time; never stored.
687
+ */
688
+ agentId: import_zod2.z.string().min(1).max(64).optional()
656
689
  });
657
690
  var LEAKY_MODEL_MIGRATION = {
658
691
  "accounts/fireworks/models/kimi-k2p7-code": "accounts/fireworks/models/kimi-k3"
@@ -675,14 +708,24 @@ function resolvePersonaConfig(config, publishableKey) {
675
708
  return parseFrankieConfig({
676
709
  ...config,
677
710
  personas: [],
711
+ agentId: persona.id,
678
712
  branding: { ...config.branding, ...persona.branding ?? {} },
679
713
  auth: { ...config.auth, publishableKey: persona.publishableKey, allowAnonymous: persona.allowAnonymous ?? config.auth.allowAnonymous },
680
714
  pagePatterns: persona.pagePatterns,
681
- agent: persona.agent,
715
+ agent: { ...persona.agent, systemPrompt: personaInstructions(persona) },
682
716
  features: persona.features,
683
717
  knowledgeBase: persona.knowledgeBase
684
718
  });
685
719
  }
720
+ function personaInstructions(persona) {
721
+ const base = persona.agent.systemPrompt.trim();
722
+ const scope = persona.scope?.trim();
723
+ const handsOff = persona.features.escalation.enabled;
724
+ const contact = !handsOff ? persona.contactUrl?.trim() : void 0;
725
+ const fallback = handsOff ? "" : contact ? `When you can't answer from what you know, or someone asks for a person, say so briefly and point them to ${contact} to reach the team. Never guess.` : "When you can't answer from what you know, say so plainly. Never guess, and never promise that someone will follow up.";
726
+ const boundary = "Stay within this. If someone asks about something outside it, say briefly that it isn't something you can help with here" + (handsOff ? ", and offer to connect them with the team." : contact ? ", and share the contact link." : ".");
727
+ return [base, scope ? "What you help with, in the team's own words:\n" + scope + "\n\n" + boundary : "", fallback].filter(Boolean).join("\n\n");
728
+ }
686
729
  function normalizeRoutePath(value) {
687
730
  let path = (value || "/").split(/[?#]/)[0].trim();
688
731
  if (!path.startsWith("/")) path = "/" + path;
@@ -1336,6 +1379,65 @@ function relativeImport(fromFile, module2) {
1336
1379
  return up === 0 ? `./${rel}` : rel;
1337
1380
  }
1338
1381
 
1382
+ // src/install-prompt.ts
1383
+ function installPrompt(options = {}) {
1384
+ const workspace = options.workspaceId || "<workspace-id>";
1385
+ const key = options.publishableKey || "<publishable-key>";
1386
+ const anonymous = options.allowAnonymous ?? false;
1387
+ const cli = "npx @frankieai/cli@latest setup --workspace " + workspace + " --key " + key + (anonymous ? " --anonymous" : "");
1388
+ return `Add Frankie, an in-app AI assistant, to this app. Work in this repository only and keep the change small.
1389
+
1390
+ ## What Frankie is
1391
+ - The chat is a React component (@frankieai/react) that runs in this app's frontend.
1392
+ - The agent runs on Frankie's hosted cloud. Do not add a Frankie server, a model provider or any AI API key to this project.
1393
+ - Actions run in the user's browser against this app's own API, with the user's existing session. Do not add API credentials for Frankie.
1394
+ - Workspace ID: ${workspace}
1395
+ - Publishable key: ${key} (public, safe in the browser)
1396
+ - FRANKIE_JWT_SECRET is server-only. Never write its value anywhere, never print it, never commit it. Only reference it as an environment variable.
1397
+
1398
+ ## Steps
1399
+ 1. Inspect the repository: the framework, the package manager, and how users sign in today (Auth.js/NextAuth, Firebase session cookies, Supabase, Clerk, a custom session\u2026). In a monorepo, pick the app users sign in to and tell me which one you chose.
1400
+
1401
+ 2. If it is a Next.js App Router app, use Frankie's installer instead of writing files by hand:
1402
+ - Make sure the git tree is clean and the default branch is checked out.
1403
+ - Preview: ${cli} --dry-run
1404
+ - Monorepo: add --app <path-to-app>, for example --app apps/dashboard
1405
+ - Then run it with --no-pr to write the files and commit them on a new branch.
1406
+ - Read the generated FRANKIE_SETUP.md and app/api/frankie/token/route.ts. If the installer could not detect sign-in, implement getCurrentUser in lib/frankie-auth.ts using the app's existing session (see step 3b), and commit that too.
1407
+
1408
+ 3. Otherwise (any other React app), add it by hand:
1409
+ a. Install @frankieai/react with the project's package manager.
1410
+ b. Mount it once, at the root of the signed-in part of the app (a client component in frameworks that need one):
1411
+ import { FrankieProvider, FrankieWidget } from "@frankieai/react";
1412
+ import "@frankieai/react/styles.css";
1413
+ <FrankieProvider publishableKey={/* the public key from env */}${anonymous ? "" : `
1414
+ getUserToken={async () => {
1415
+ const res = await fetch("/api/frankie/token", { method: "POST" });
1416
+ return res.ok ? (await res.json()).token : undefined;
1417
+ }}`}>
1418
+ <FrankieWidget />
1419
+ </FrankieProvider>
1420
+ Read the key from a public environment variable using the framework's convention (NEXT_PUBLIC_FRANKIE_KEY, VITE_FRANKIE_KEY, \u2026).
1421
+ If the app's API is on a different origin than its pages, also pass api={{ baseUrl: "<that origin>", credentials: "include" }}.${anonymous ? "" : `
1422
+ c. Add a server endpoint POST /api/frankie/token on the app's own backend:
1423
+ - Look up the signed-in user with the session code the app already uses. Never invent a new auth system.
1424
+ - Not signed in: respond 401.
1425
+ - Signed in: respond { "token": "<jwt>" }, a JWT signed HS256 with process.env.FRANKIE_JWT_SECRET, subject = the user's stable id, optional name and email claims, expiring in 5 minutes (the "jose" package works well).`}
1426
+
1427
+ 4. Add the variables to .env.example (or the project's equivalent) with empty values and a one-line comment each: the public key variable${anonymous ? "" : " and FRANKIE_JWT_SECRET (server only)"}. Do not put real values in any committed file.
1428
+
1429
+ 5. Run the project's typecheck, lint and build, and fix anything this change broke. Do not change unrelated code.
1430
+
1431
+ 6. Finish with a short summary:
1432
+ - The files you added or changed, and the branch they are on.
1433
+ - What I still need to do:
1434
+ - Set the variables in my deployment and in my local env file. The values are in Frankie, under the workspace's Keys & settings.
1435
+ - Add every origin the app runs on, including local development like http://localhost:3000, to Allowed sites in Frankie (Keys & settings).
1436
+ - Push the branch and open a pull request titled "Install Frankie".
1437
+ - Connect this repository on the workspace's Install tab so Frankie tracks the pull request.
1438
+ - Anything you could not do, and why.`;
1439
+ }
1440
+
1339
1441
  // src/sdk-updates.ts
1340
1442
  function sdkUpdateWorkflow(mode) {
1341
1443
  if (mode !== "manual" && mode !== "automatic") throw new Error("Invalid update mode.");
@@ -1602,6 +1704,7 @@ function oklchToHex(l, c, hDeg) {
1602
1704
  var NUMBER = String.raw`-?\d*\.?\d+`;
1603
1705
  var parts = (body) => body.replace(/\/.*$/, "").split(/[\s,]+/).filter(Boolean);
1604
1706
  function normalizeColor(raw) {
1707
+ if (raw.length > 200) return void 0;
1605
1708
  const value = raw.trim().replace(/\s*!important$/, "").toLowerCase();
1606
1709
  let match = value.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
1607
1710
  if (match) {
@@ -1628,20 +1731,57 @@ function normalizeColor(raw) {
1628
1731
  }
1629
1732
  return void 0;
1630
1733
  }
1734
+ function cssBlocks(source) {
1735
+ const blocks = [];
1736
+ let lastBrace = -1;
1737
+ let open = -1;
1738
+ let selectorStart = 0;
1739
+ for (let index = 0; index < source.length; index++) {
1740
+ const char = source.charCodeAt(index);
1741
+ if (char === 123) {
1742
+ selectorStart = lastBrace + 1;
1743
+ open = index;
1744
+ lastBrace = index;
1745
+ } else if (char === 125) {
1746
+ if (open !== -1) blocks.push([source.slice(selectorStart, open), source.slice(open + 1, index)]);
1747
+ open = -1;
1748
+ lastBrace = index;
1749
+ }
1750
+ }
1751
+ return blocks;
1752
+ }
1753
+ function customProperty(declaration) {
1754
+ const colon = declaration.indexOf(":");
1755
+ if (colon === -1) return void 0;
1756
+ const name = declaration.slice(0, colon).trim();
1757
+ const value = declaration.slice(colon + 1).trim();
1758
+ return name.length <= 100 && value.length <= 200 && /^--[\w-]+$/.test(name) && value ? [name, value] : void 0;
1759
+ }
1631
1760
  function rootVariables(css) {
1632
1761
  const vars = /* @__PURE__ */ new Map();
1633
- const source = css.replace(/\/\*[\s\S]*?\*\//g, "");
1634
- const blocks = /([^{}]*)\{([^{}]*)\}/g;
1635
- for (let block = blocks.exec(source); block; block = blocks.exec(source)) {
1636
- const selector = block[1].split(";").pop().trim();
1762
+ const source = stripComments(css);
1763
+ for (const [rawSelector, body] of cssBlocks(source)) {
1764
+ const selector = rawSelector.split(";").pop().trim();
1765
+ if (selector.length > 1e3) continue;
1637
1766
  if (!/(?:^|[\s,]):root\b(?![^,]*\.dark)|^@theme\b/.test(selector) || /\.dark|\[data-theme/.test(selector)) continue;
1638
- for (const declaration of block[2].split(";")) {
1639
- const found = declaration.match(/^\s*(--[\w-]+)\s*:\s*([\s\S]+?)\s*$/);
1640
- if (found && !vars.has(found[1])) vars.set(found[1], found[2]);
1767
+ for (const declaration of body.split(";")) {
1768
+ const found = customProperty(declaration);
1769
+ if (found && !vars.has(found[0])) vars.set(found[0], found[1]);
1641
1770
  }
1642
1771
  }
1643
1772
  return vars;
1644
1773
  }
1774
+ function stripComments(css) {
1775
+ let out = "";
1776
+ for (let at = 0; ; ) {
1777
+ const start = css.indexOf("/*", at);
1778
+ if (start === -1) return out + css.slice(at);
1779
+ out += css.slice(at, start);
1780
+ const end = css.indexOf("*/", start + 2);
1781
+ if (end === -1) return out;
1782
+ at = end + 2;
1783
+ }
1784
+ }
1645
1785
  function resolve(value, vars, depth = 0) {
1646
1786
  if (depth > 4) return void 0;
1647
1787
  const reference = value.match(/^(?:(hsla?|oklch|rgba?)\(\s*)?var\(\s*(--[\w-]+)\s*(?:,[^)]*)?\)\s*\)?$/i);
@@ -1657,6 +1797,61 @@ function radiusOption(value) {
1657
1797
  const rem = match[2] === "px" ? parseFloat(match[1]) / 16 : parseFloat(match[1]);
1658
1798
  return rem < 0.525 ? "0.35" : rem < 0.9 ? "0.7" : "1.1";
1659
1799
  }
1800
+ function saturationLightness(hex) {
1801
+ const [r, g, b] = [1, 3, 5].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255);
1802
+ const max = Math.max(r, g, b);
1803
+ const min = Math.min(r, g, b);
1804
+ const lightness = (max + min) / 2;
1805
+ const saturation = max === min ? 0 : (max - min) / (1 - Math.abs(2 * lightness - 1));
1806
+ return [saturation, lightness];
1807
+ }
1808
+ function isVivid(hex) {
1809
+ if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) return false;
1810
+ const [saturation, lightness] = saturationLightness(hex);
1811
+ return saturation >= 0.35 && lightness >= 0.2 && lightness <= 0.8;
1812
+ }
1813
+ var BUTTON_SELECTOR = /(?:^|[\s,>+~])(?:button|\.btn[\w-]*|\.button[\w-]*|\[type=["']?submit["']?\]|\.primary[\w-]*)(?![\w-])/i;
1814
+ var PAGE_SELECTOR = /^(?:html|body|:root)(?:\s*,\s*(?:html|body|:root))*$/i;
1815
+ function pageColors(css, vars) {
1816
+ const out = {};
1817
+ const source = stripComments(css);
1818
+ const declarations = (body) => body.split(";").filter((part) => part.length <= 300).map((part) => {
1819
+ const colon = part.indexOf(":");
1820
+ if (colon === -1) return void 0;
1821
+ let value = part.slice(colon + 1).trim();
1822
+ if (value.toLowerCase().endsWith("!important")) value = value.slice(0, -"!important".length).trim();
1823
+ return [part.slice(0, colon).trim().toLowerCase(), value];
1824
+ }).filter((entry) => Boolean(entry));
1825
+ const colorOf = (value) => resolve(value, vars) ?? value.split(/\s+(?![^(]*\))/).map((part) => resolve(part, vars)).find(Boolean);
1826
+ for (const [rawSelector, body] of cssBlocks(source)) {
1827
+ const selector = rawSelector.split(";").pop().trim();
1828
+ if (selector.length > 1e3 || /\.dark|\[data-theme|:hover|:active|:disabled|:focus/.test(selector)) continue;
1829
+ if (PAGE_SELECTOR.test(selector)) {
1830
+ for (const [property, value] of declarations(body)) {
1831
+ if (!out.background && (property === "background" || property === "background-color")) out.background = colorOf(value);
1832
+ if (!out.foreground && property === "color") out.foreground = colorOf(value);
1833
+ }
1834
+ } else if (!out.button && BUTTON_SELECTOR.test(selector) && !/ghost|outline|secondary|link|danger|red|cancel/i.test(selector)) {
1835
+ for (const [property, value] of declarations(body)) {
1836
+ if (property === "background" || property === "background-color" || /^--[\w-]*(?:bg|background)$/.test(property)) {
1837
+ const color = colorOf(value);
1838
+ if (isVivid(color)) {
1839
+ out.button = color;
1840
+ break;
1841
+ }
1842
+ }
1843
+ }
1844
+ }
1845
+ }
1846
+ const counts = /* @__PURE__ */ new Map();
1847
+ const seen = /var\(\s*(--[\w-]+)\s*[,)]/g;
1848
+ for (let match = seen.exec(source); match; match = seen.exec(source)) {
1849
+ const color = resolve("var(" + match[1] + ")", vars);
1850
+ if (isVivid(color)) counts.set(color, (counts.get(color) ?? 0) + 1);
1851
+ }
1852
+ out.mostUsed = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
1853
+ return out;
1854
+ }
1660
1855
  var CSS_PRIORITY = [/(?:^|\/)app\/globals\.css$/, /(?:^|\/)styles\/globals\.css$/, /globals\.css$/, /\.css$/];
1661
1856
  var PRIMARY_VARS = ["--primary", "--color-primary", "--brand", "--color-brand", "--accent", "--color-accent"];
1662
1857
  function rank(path) {
@@ -1685,6 +1880,13 @@ function inferTheme(files, root = "") {
1685
1880
  for (const name of ["--background", "--color-background"]) if (vars.has(name)) set("background", resolve(vars.get(name), vars), varPath.get(name));
1686
1881
  for (const name of ["--foreground", "--color-foreground"]) if (vars.has(name)) set("foreground", resolve(vars.get(name), vars), varPath.get(name));
1687
1882
  if (vars.has("--radius")) set("radius", radiusOption(vars.get("--radius")), varPath.get("--radius"));
1883
+ const guessedPrimary = [];
1884
+ for (const path of cssFiles) {
1885
+ const used = pageColors(files[path], vars);
1886
+ set("background", used.background, path);
1887
+ set("foreground", used.foreground, path);
1888
+ guessedPrimary.push([used.button ?? used.mostUsed, path]);
1889
+ }
1688
1890
  for (const path of Object.keys(files).filter((p) => inRoot(p) && /(?:^|\/)tailwind\.config\.[cm]?[jt]s$/.test(p)).sort()) {
1689
1891
  const source = files[path];
1690
1892
  for (const key of ["primary", "brand", "accent"]) {
@@ -1706,11 +1908,242 @@ function inferTheme(files, root = "") {
1706
1908
  } catch {
1707
1909
  }
1708
1910
  }
1911
+ for (const [color, path] of guessedPrimary) set("primary", color, path);
1709
1912
  if (!theme.primary) return void 0;
1710
1913
  return { theme: { ...theme, primary: theme.primary }, sources };
1711
1914
  }
1915
+
1916
+ // src/conversation-log.ts
1917
+ function userLabel(user) {
1918
+ if (user.displayName?.trim()) return user.displayName.trim();
1919
+ if (user.email?.trim()) return user.email.trim();
1920
+ if (user.anonymous) return "Anonymous visitor";
1921
+ return "Signed-in user \xB7 " + shortId(user.id);
1922
+ }
1923
+ function shortId(id) {
1924
+ const plain = id.replace(/^[a-z]+[:_]/i, "");
1925
+ return plain.length > 8 ? plain.slice(0, 8) : plain;
1926
+ }
1927
+ function conversationLog(conversation, options = {}) {
1928
+ const { user, messages, agentMessages } = conversation;
1929
+ const tools = messages.flatMap(toolsOf);
1930
+ const failedTools = tools.filter((tool) => tool.status === "failed" || tool.status === "denied").length;
1931
+ const out = [];
1932
+ out.push("# Frankie conversation log", "");
1933
+ out.push(
1934
+ "Frankie is an AI assistant embedded in a customer's app. This log has the chat as the person saw it, then the agent's full trace: model messages, tool calls with their inputs, tool results and errors. Use it to explain what happened and why.",
1935
+ ""
1936
+ );
1937
+ out.push("## Summary", "");
1938
+ out.push(`- Conversation: ${conversation.title || "Untitled"} (\`${conversation.id}\`)`);
1939
+ if (options.workspace) out.push(`- Workspace: ${options.workspace.name} (\`${options.workspace.id}\`)`);
1940
+ out.push(`- Person: ${userLabel(user)} (\`${user.id}\`, ${user.anonymous ? "anonymous" : "signed in"})`);
1941
+ if (options.agent) out.push(`- Agent: ${options.agent}`);
1942
+ out.push(`- Started: ${iso(conversation.createdAt)}`);
1943
+ out.push(`- Last active: ${iso(conversation.updatedAt)}`);
1944
+ out.push(`- Messages: ${messages.length} \xB7 Actions: ${tools.length}${failedTools ? ` (${failedTools} failed)` : ""}`);
1945
+ out.push(`- Cost: ${conversation.costUsd > 0 ? "$" + conversation.costUsd.toFixed(4) : "not reported"}`);
1946
+ out.push(`- State: ${conversation.busy ? "still responding" : "finished"}`);
1947
+ out.push("");
1948
+ const escalations = conversation.escalations ?? [];
1949
+ if (escalations.length) {
1950
+ out.push("## Handed to the team", "");
1951
+ for (const escalation of escalations) {
1952
+ const contact = [escalation.name, escalation.email].filter(Boolean).join(", ");
1953
+ out.push(`- ${iso(escalation.at)}: ${escalation.reason}${contact ? ` (left ${contact})` : ""}`);
1954
+ if (escalation.summary) out.push(` ${escalation.summary.replace(/\n+/g, " ")}`);
1955
+ }
1956
+ out.push("");
1957
+ }
1958
+ out.push("## Chat (what the person saw)", "");
1959
+ if (!messages.length) out.push("_The chat widget saved no messages for this conversation._", "");
1960
+ for (const message of messages) {
1961
+ const item = record(message);
1962
+ if (!item) continue;
1963
+ const role = str(item.role) ?? "message";
1964
+ const speaker = role === "user" ? `Person (${userLabel(user)})` : role === "assistant" ? options.agent ?? "Agent" : role;
1965
+ const at = num(item.createdAt);
1966
+ out.push(`### ${speaker}${at ? ` \xB7 ${iso(at)}` : ""}`, "");
1967
+ const text = textOf(item);
1968
+ if (text) out.push(clip(text), "");
1969
+ for (const file of list(item.attachments)) out.push(`_Attached: ${str(file.name) ?? "file"} (${str(file.mimeType) ?? "unknown type"})_`);
1970
+ const messageTools = toolsOf(item);
1971
+ if (messageTools.length) {
1972
+ out.push("Actions:");
1973
+ for (const tool of messageTools) {
1974
+ const detail = str(tool.error) ?? str(tool.detail);
1975
+ out.push(`- [${str(tool.status) ?? "done"}] ${str(tool.label) ?? str(tool.name) ?? "tool"}${tool.name ? ` (\`${str(tool.name)}\`)` : ""}${detail ? `: ${oneLine(detail, 300)}` : ""}`);
1976
+ }
1977
+ }
1978
+ const error = str(item.error);
1979
+ if (error || item.status === "error") out.push("", `**This reply didn't finish:** ${error ?? "no error was recorded"}`);
1980
+ out.push("");
1981
+ }
1982
+ out.push("## Agent trace (model messages, tool calls and results)", "");
1983
+ if (!agentMessages.length) out.push("_The server recorded no agent messages for this conversation._", "");
1984
+ agentMessages.forEach((message, index) => {
1985
+ const item = record(message);
1986
+ if (!item) return;
1987
+ const role = str(item.role) ?? "message";
1988
+ const at = num(item.timestamp);
1989
+ if (role === "toolResult" || role === "tool_result") {
1990
+ const failed = item.isError === true;
1991
+ out.push(`### ${index + 1}. Tool result \xB7 \`${str(item.toolName) ?? "tool"}\`${failed ? " \xB7 ERROR" : ""}${at ? ` \xB7 ${iso(at)}` : ""}`, "");
1992
+ out.push(fence(contentText(item.content)), "");
1993
+ return;
1994
+ }
1995
+ const heading = [`${index + 1}. ${role === "assistant" ? "Assistant" : role === "user" ? "User" : role}`];
1996
+ if (role === "assistant") {
1997
+ if (str(item.model)) heading.push(str(item.model));
1998
+ if (str(item.stopReason)) heading.push(`stop: ${str(item.stopReason)}`);
1999
+ }
2000
+ if (at) heading.push(iso(at));
2001
+ out.push(`### ${heading.join(" \xB7 ")}`, "");
2002
+ const errorMessage = str(item.errorMessage) ?? str(item.error);
2003
+ if (errorMessage) out.push(`**Error:** ${errorMessage}`, "");
2004
+ if (typeof item.content === "string") {
2005
+ out.push(clip(item.content), "");
2006
+ return;
2007
+ }
2008
+ const before = out.length;
2009
+ for (const block of list(item.content)) {
2010
+ const type = str(block.type);
2011
+ if (type === "text") {
2012
+ if (str(block.text)) out.push(clip(str(block.text)), "");
2013
+ } else if (type === "thinking") {
2014
+ if (str(block.thinking) && block.redacted !== true) out.push("Thinking:", fence(str(block.thinking)), "");
2015
+ } else if (type === "toolCall") out.push(`Tool call \`${str(block.name) ?? "tool"}\`${str(block.id) ? ` (id \`${str(block.id)}\`)` : ""}:`, fence(json(block.arguments ?? {}), "json"), "");
2016
+ else if (type === "image") out.push(`_[image: ${str(block.mimeType) ?? "image"}, not included]_`, "");
2017
+ else if (type) out.push(`${type}:`, fence(json(stripLarge(block)), "json"), "");
2018
+ }
2019
+ if (out.length === before && !errorMessage) out.push("_(empty: no text and no tool calls)_", "");
2020
+ });
2021
+ return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
2022
+ }
2023
+ function conversationLogFileName(conversation) {
2024
+ const slug = (conversation.title || "conversation").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "conversation";
2025
+ return `frankie-${slug}-${conversation.id.slice(0, 12)}.md`;
2026
+ }
2027
+ var BLOCK_LIMIT = 12e3;
2028
+ function record(value) {
2029
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
2030
+ }
2031
+ function list(value) {
2032
+ return Array.isArray(value) ? value.map(record).filter((item) => Boolean(item)) : [];
2033
+ }
2034
+ function str(value) {
2035
+ return typeof value === "string" && value.trim() ? value : void 0;
2036
+ }
2037
+ function num(value) {
2038
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
2039
+ }
2040
+ function iso(at) {
2041
+ const date = new Date(at);
2042
+ return Number.isNaN(date.getTime()) ? "unknown time" : date.toISOString().replace(/\.\d{3}Z$/, "Z");
2043
+ }
2044
+ function toolsOf(message) {
2045
+ return list(record(message)?.tools);
2046
+ }
2047
+ function textOf(item) {
2048
+ if (typeof item.text === "string") return item.text;
2049
+ if (typeof item.content === "string") return item.content;
2050
+ return contentText(item.content);
2051
+ }
2052
+ function contentText(content) {
2053
+ if (typeof content === "string") return content;
2054
+ return list(content).map((block) => block.type === "image" ? `[image: ${str(block.mimeType) ?? "image"}, not included]` : str(block.text) ?? "").filter(Boolean).join("\n");
2055
+ }
2056
+ function stripLarge(block) {
2057
+ const out = {};
2058
+ for (const [key, value] of Object.entries(block)) {
2059
+ if (/signature$/i.test(key)) continue;
2060
+ out[key] = typeof value === "string" && value.length > 2e3 && /^[A-Za-z0-9+/=]+$/.test(value) ? `[${value.length} characters of binary data]` : value;
2061
+ }
2062
+ return out;
2063
+ }
2064
+ function json(value) {
2065
+ try {
2066
+ return JSON.stringify(value, null, 2) ?? String(value);
2067
+ } catch {
2068
+ return String(value);
2069
+ }
2070
+ }
2071
+ function clip(text, limit = BLOCK_LIMIT) {
2072
+ return text.length > limit ? `${text.slice(0, limit)}
2073
+ \u2026 [${text.length - limit} more characters cut]` : text;
2074
+ }
2075
+ function oneLine(text, limit) {
2076
+ const flat = text.replace(/\s+/g, " ").trim();
2077
+ return flat.length > limit ? `${flat.slice(0, limit)}\u2026` : flat;
2078
+ }
2079
+ function fence(text, lang = "") {
2080
+ const body = clip(text || "(empty)");
2081
+ const longest = Math.max(2, ...Array.from(body.matchAll(/`+/g), (match) => match[0].length));
2082
+ const ticks = "`".repeat(longest + 1);
2083
+ return `${ticks}${lang}
2084
+ ${body}
2085
+ ${ticks}`;
2086
+ }
2087
+
2088
+ // src/api-sync.ts
2089
+ var API_SYNC_WORKFLOW_PATH = ".github/workflows/frankie-api-sync.yml";
2090
+ function apiSyncWorkflow(options) {
2091
+ const { url, workspace, api, path } = options;
2092
+ if (!/^https:\/\/[a-z0-9.-]+(?::\d+)?(?:\/[\w./-]*)?$/i.test(url) && !/^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/.test(url)) throw new Error("Invalid Frankie URL.");
2093
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/i.test(workspace)) throw new Error("Invalid workspace id.");
2094
+ if (!/^[\w.-]{1,80}$/.test(api)) throw new Error("Invalid API name.");
2095
+ if (!isSafeRepositoryPath(path)) throw new Error("Choose a JSON or YAML file in the repository, like public/openapi.json.");
2096
+ return template2.replaceAll("__PATH__", path).replaceAll("__URL__", url.replace(/\/+$/, "")).replaceAll("__WORKSPACE__", workspace).replaceAll("__API__", api);
2097
+ }
2098
+ function isSafeRepositoryPath(path) {
2099
+ return /^[\w@.-]+(?:\/[\w@.-]+)*\.(?:json|ya?ml)$/i.test(path) && !path.split("/").some((part) => part === ".." || part === ".");
2100
+ }
2101
+ var template2 = String.raw`# Managed by Frankie: keeps Frankie's copy of your API description in step.
2102
+ # frankie-api-sync: __WORKSPACE__/__API__
2103
+ # Runs when __PATH__ changes on the default branch. No secrets: GitHub's OIDC
2104
+ # token proves which repository and commit this is. New or changed actions
2105
+ # stay off in Frankie until you allow them.
2106
+ name: Frankie API sync
2107
+ on:
2108
+ push:
2109
+ paths:
2110
+ - "__PATH__"
2111
+ workflow_dispatch:
2112
+ permissions:
2113
+ contents: read
2114
+ id-token: write
2115
+ concurrency:
2116
+ group: frankie-api-sync
2117
+ cancel-in-progress: true
2118
+ jobs:
2119
+ sync:
2120
+ if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
2121
+ runs-on: ubuntu-latest
2122
+ timeout-minutes: 5
2123
+ steps:
2124
+ - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
2125
+ with:
2126
+ persist-credentials: false
2127
+ sparse-checkout: __PATH__
2128
+ sparse-checkout-cone-mode: false
2129
+ - name: Send the API description to Frankie
2130
+ env:
2131
+ FRANKIE_URL: "__URL__"
2132
+ FRANKIE_WORKSPACE: "__WORKSPACE__"
2133
+ FRANKIE_API: "__API__"
2134
+ SPEC_PATH: "__PATH__"
2135
+ run: |
2136
+ test -f "$SPEC_PATH"
2137
+ token="$(curl -sSf -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=frankie" | jq -r .value)"
2138
+ jq -n --arg workspace "$FRANKIE_WORKSPACE" --arg api "$FRANKIE_API" --arg path "$SPEC_PATH" --rawfile spec "$SPEC_PATH" \
2139
+ '{workspace: $workspace, api: $api, path: $path, spec: $spec}' > "$RUNNER_TEMP/frankie-api.json"
2140
+ curl -sS --fail-with-body -X POST "$FRANKIE_URL/github/api-sync" \
2141
+ -H "Authorization: Bearer $token" -H "content-type: application/json" \
2142
+ --data "@$RUNNER_TEMP/frankie-api.json"
2143
+ `;
1712
2144
  // Annotate the CommonJS export names for ESM import in node:
1713
2145
  0 && (module.exports = {
2146
+ API_SYNC_WORKFLOW_PATH,
1714
2147
  FIREWORKS_MODELS,
1715
2148
  SKILL_TOOL_NAME,
1716
2149
  STANDARD_AGENT_IDS,
@@ -1721,12 +2154,15 @@ function inferTheme(files, root = "") {
1721
2154
  apiMethods,
1722
2155
  apiOperations,
1723
2156
  apiSourceSchema,
2157
+ apiSyncWorkflow,
1724
2158
  approvedSkillTools,
1725
2159
  assertNoCommittedFrankieKeys,
1726
2160
  authConfigSchema,
1727
2161
  brandingConfigSchema,
1728
2162
  canonicalJson,
1729
2163
  compareApiDocuments,
2164
+ conversationLog,
2165
+ conversationLogFileName,
1730
2166
  defineFrankieConfig,
1731
2167
  describeTool,
1732
2168
  draftSkill,
@@ -1737,7 +2173,9 @@ function inferTheme(files, root = "") {
1737
2173
  insertImport,
1738
2174
  installAgentIds,
1739
2175
  installBranch,
2176
+ installPrompt,
1740
2177
  installTitle,
2178
+ isSafeRepositoryPath,
1741
2179
  knowledgeBaseSchema,
1742
2180
  knowledgeSourceSchema,
1743
2181
  limitsConfigSchema,
@@ -1754,6 +2192,7 @@ function inferTheme(files, root = "") {
1754
2192
  operationId,
1755
2193
  parseFrankieConfig,
1756
2194
  personaEnvKey,
2195
+ personaInstructions,
1757
2196
  personaProfileSchema,
1758
2197
  plainText,
1759
2198
  planHostedInstall,
@@ -1766,6 +2205,7 @@ function inferTheme(files, root = "") {
1766
2205
  thinkingLevelSchema,
1767
2206
  toolDescriptions,
1768
2207
  toolPolicySchema,
2208
+ userLabel,
1769
2209
  validateApiDocument,
1770
2210
  widgetThemeSchema,
1771
2211
  withDependencies