@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 +450 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +156 -1
- package/dist/index.d.ts +156 -1
- package/dist/index.js +442 -10
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -449,7 +449,14 @@ var agentConfigSchema = z2.object({
|
|
|
449
449
|
maxBudgetUsd: z2.number().positive().optional(),
|
|
450
450
|
tools: toolPolicySchema.prefault({}),
|
|
451
451
|
/** Show tool activity in the transcript. */
|
|
452
|
-
showToolActivity: z2.boolean().default(true)
|
|
452
|
+
showToolActivity: z2.boolean().default(true),
|
|
453
|
+
/**
|
|
454
|
+
* Ask the user before any action that changes data runs. The chat shows the
|
|
455
|
+
* action with Approve and Cancel; it runs only on Approve, with exactly the
|
|
456
|
+
* inputs shown. Reads never ask. Applies to chats from an SDK that can show
|
|
457
|
+
* the card; older SDK versions and headless callers run as before.
|
|
458
|
+
*/
|
|
459
|
+
confirmWrites: z2.boolean().default(true)
|
|
453
460
|
});
|
|
454
461
|
var authConfigSchema = z2.object({
|
|
455
462
|
/**
|
|
@@ -529,6 +536,18 @@ var personaProfileSchema = z2.object({
|
|
|
529
536
|
* sign in.
|
|
530
537
|
*/
|
|
531
538
|
allowAnonymous: z2.boolean().optional(),
|
|
539
|
+
/**
|
|
540
|
+
* What this agent helps with, in the team's own words ("pricing, plans,
|
|
541
|
+
* getting started"). The one thing a team needs to write; the agent's role,
|
|
542
|
+
* greeting and instructions come from defaults. It is added to the agent's
|
|
543
|
+
* instructions at runtime (see `personaInstructions`).
|
|
544
|
+
*/
|
|
545
|
+
scope: z2.string().trim().max(2e3).optional(),
|
|
546
|
+
/**
|
|
547
|
+
* Where to send people when the agent can't answer and it doesn't hand the
|
|
548
|
+
* chat to the team itself: a contact page (https) or an email (mailto:).
|
|
549
|
+
*/
|
|
550
|
+
contactUrl: z2.string().trim().max(500).regex(/^(?:https:\/\/[^\s]+|mailto:[^\s@]+@[^\s@]+)$/, "Use an https:// link or a mailto: address.").optional(),
|
|
532
551
|
/**
|
|
533
552
|
* Shallow-merged over the workspace branding. Each field is optional with no
|
|
534
553
|
* default, so only the keys the persona sets override the workspace's — an
|
|
@@ -569,7 +588,13 @@ var frankieConfigSchema = z2.object({
|
|
|
569
588
|
* is the primary persona. Each entry resolves to its own one-agent config via
|
|
570
589
|
* its own publishable key, so the data plane still sees one config per key.
|
|
571
590
|
*/
|
|
572
|
-
personas: z2.array(personaProfileSchema).max(8).default([])
|
|
591
|
+
personas: z2.array(personaProfileSchema).max(8).default([]),
|
|
592
|
+
/**
|
|
593
|
+
* Which agent a *resolved* config is: absent for the primary (product)
|
|
594
|
+
* agent, the persona's id (support, docs, …) otherwise. Set only by
|
|
595
|
+
* resolvePersonaConfig at request time; never stored.
|
|
596
|
+
*/
|
|
597
|
+
agentId: z2.string().min(1).max(64).optional()
|
|
573
598
|
});
|
|
574
599
|
var LEAKY_MODEL_MIGRATION = {
|
|
575
600
|
"accounts/fireworks/models/kimi-k2p7-code": "accounts/fireworks/models/kimi-k3"
|
|
@@ -592,14 +617,24 @@ function resolvePersonaConfig(config, publishableKey) {
|
|
|
592
617
|
return parseFrankieConfig({
|
|
593
618
|
...config,
|
|
594
619
|
personas: [],
|
|
620
|
+
agentId: persona.id,
|
|
595
621
|
branding: { ...config.branding, ...persona.branding ?? {} },
|
|
596
622
|
auth: { ...config.auth, publishableKey: persona.publishableKey, allowAnonymous: persona.allowAnonymous ?? config.auth.allowAnonymous },
|
|
597
623
|
pagePatterns: persona.pagePatterns,
|
|
598
|
-
agent: persona.agent,
|
|
624
|
+
agent: { ...persona.agent, systemPrompt: personaInstructions(persona) },
|
|
599
625
|
features: persona.features,
|
|
600
626
|
knowledgeBase: persona.knowledgeBase
|
|
601
627
|
});
|
|
602
628
|
}
|
|
629
|
+
function personaInstructions(persona) {
|
|
630
|
+
const base = persona.agent.systemPrompt.trim();
|
|
631
|
+
const scope = persona.scope?.trim();
|
|
632
|
+
const handsOff = persona.features.escalation.enabled;
|
|
633
|
+
const contact = !handsOff ? persona.contactUrl?.trim() : void 0;
|
|
634
|
+
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.";
|
|
635
|
+
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." : ".");
|
|
636
|
+
return [base, scope ? "What you help with, in the team's own words:\n" + scope + "\n\n" + boundary : "", fallback].filter(Boolean).join("\n\n");
|
|
637
|
+
}
|
|
603
638
|
function normalizeRoutePath(value) {
|
|
604
639
|
let path = (value || "/").split(/[?#]/)[0].trim();
|
|
605
640
|
if (!path.startsWith("/")) path = "/" + path;
|
|
@@ -1253,6 +1288,65 @@ function relativeImport(fromFile, module) {
|
|
|
1253
1288
|
return up === 0 ? `./${rel}` : rel;
|
|
1254
1289
|
}
|
|
1255
1290
|
|
|
1291
|
+
// src/install-prompt.ts
|
|
1292
|
+
function installPrompt(options = {}) {
|
|
1293
|
+
const workspace = options.workspaceId || "<workspace-id>";
|
|
1294
|
+
const key = options.publishableKey || "<publishable-key>";
|
|
1295
|
+
const anonymous = options.allowAnonymous ?? false;
|
|
1296
|
+
const cli = "npx @frankieai/cli@latest setup --workspace " + workspace + " --key " + key + (anonymous ? " --anonymous" : "");
|
|
1297
|
+
return `Add Frankie, an in-app AI assistant, to this app. Work in this repository only and keep the change small.
|
|
1298
|
+
|
|
1299
|
+
## What Frankie is
|
|
1300
|
+
- The chat is a React component (@frankieai/react) that runs in this app's frontend.
|
|
1301
|
+
- 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.
|
|
1302
|
+
- 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.
|
|
1303
|
+
- Workspace ID: ${workspace}
|
|
1304
|
+
- Publishable key: ${key} (public, safe in the browser)
|
|
1305
|
+
- FRANKIE_JWT_SECRET is server-only. Never write its value anywhere, never print it, never commit it. Only reference it as an environment variable.
|
|
1306
|
+
|
|
1307
|
+
## Steps
|
|
1308
|
+
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.
|
|
1309
|
+
|
|
1310
|
+
2. If it is a Next.js App Router app, use Frankie's installer instead of writing files by hand:
|
|
1311
|
+
- Make sure the git tree is clean and the default branch is checked out.
|
|
1312
|
+
- Preview: ${cli} --dry-run
|
|
1313
|
+
- Monorepo: add --app <path-to-app>, for example --app apps/dashboard
|
|
1314
|
+
- Then run it with --no-pr to write the files and commit them on a new branch.
|
|
1315
|
+
- 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.
|
|
1316
|
+
|
|
1317
|
+
3. Otherwise (any other React app), add it by hand:
|
|
1318
|
+
a. Install @frankieai/react with the project's package manager.
|
|
1319
|
+
b. Mount it once, at the root of the signed-in part of the app (a client component in frameworks that need one):
|
|
1320
|
+
import { FrankieProvider, FrankieWidget } from "@frankieai/react";
|
|
1321
|
+
import "@frankieai/react/styles.css";
|
|
1322
|
+
<FrankieProvider publishableKey={/* the public key from env */}${anonymous ? "" : `
|
|
1323
|
+
getUserToken={async () => {
|
|
1324
|
+
const res = await fetch("/api/frankie/token", { method: "POST" });
|
|
1325
|
+
return res.ok ? (await res.json()).token : undefined;
|
|
1326
|
+
}}`}>
|
|
1327
|
+
<FrankieWidget />
|
|
1328
|
+
</FrankieProvider>
|
|
1329
|
+
Read the key from a public environment variable using the framework's convention (NEXT_PUBLIC_FRANKIE_KEY, VITE_FRANKIE_KEY, \u2026).
|
|
1330
|
+
If the app's API is on a different origin than its pages, also pass api={{ baseUrl: "<that origin>", credentials: "include" }}.${anonymous ? "" : `
|
|
1331
|
+
c. Add a server endpoint POST /api/frankie/token on the app's own backend:
|
|
1332
|
+
- Look up the signed-in user with the session code the app already uses. Never invent a new auth system.
|
|
1333
|
+
- Not signed in: respond 401.
|
|
1334
|
+
- 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).`}
|
|
1335
|
+
|
|
1336
|
+
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.
|
|
1337
|
+
|
|
1338
|
+
5. Run the project's typecheck, lint and build, and fix anything this change broke. Do not change unrelated code.
|
|
1339
|
+
|
|
1340
|
+
6. Finish with a short summary:
|
|
1341
|
+
- The files you added or changed, and the branch they are on.
|
|
1342
|
+
- What I still need to do:
|
|
1343
|
+
- Set the variables in my deployment and in my local env file. The values are in Frankie, under the workspace's Keys & settings.
|
|
1344
|
+
- Add every origin the app runs on, including local development like http://localhost:3000, to Allowed sites in Frankie (Keys & settings).
|
|
1345
|
+
- Push the branch and open a pull request titled "Install Frankie".
|
|
1346
|
+
- Connect this repository on the workspace's Install tab so Frankie tracks the pull request.
|
|
1347
|
+
- Anything you could not do, and why.`;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1256
1350
|
// src/sdk-updates.ts
|
|
1257
1351
|
function sdkUpdateWorkflow(mode) {
|
|
1258
1352
|
if (mode !== "manual" && mode !== "automatic") throw new Error("Invalid update mode.");
|
|
@@ -1519,6 +1613,7 @@ function oklchToHex(l, c, hDeg) {
|
|
|
1519
1613
|
var NUMBER = String.raw`-?\d*\.?\d+`;
|
|
1520
1614
|
var parts = (body) => body.replace(/\/.*$/, "").split(/[\s,]+/).filter(Boolean);
|
|
1521
1615
|
function normalizeColor(raw) {
|
|
1616
|
+
if (raw.length > 200) return void 0;
|
|
1522
1617
|
const value = raw.trim().replace(/\s*!important$/, "").toLowerCase();
|
|
1523
1618
|
let match = value.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
|
|
1524
1619
|
if (match) {
|
|
@@ -1545,20 +1640,57 @@ function normalizeColor(raw) {
|
|
|
1545
1640
|
}
|
|
1546
1641
|
return void 0;
|
|
1547
1642
|
}
|
|
1643
|
+
function cssBlocks(source) {
|
|
1644
|
+
const blocks = [];
|
|
1645
|
+
let lastBrace = -1;
|
|
1646
|
+
let open = -1;
|
|
1647
|
+
let selectorStart = 0;
|
|
1648
|
+
for (let index = 0; index < source.length; index++) {
|
|
1649
|
+
const char = source.charCodeAt(index);
|
|
1650
|
+
if (char === 123) {
|
|
1651
|
+
selectorStart = lastBrace + 1;
|
|
1652
|
+
open = index;
|
|
1653
|
+
lastBrace = index;
|
|
1654
|
+
} else if (char === 125) {
|
|
1655
|
+
if (open !== -1) blocks.push([source.slice(selectorStart, open), source.slice(open + 1, index)]);
|
|
1656
|
+
open = -1;
|
|
1657
|
+
lastBrace = index;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
return blocks;
|
|
1661
|
+
}
|
|
1662
|
+
function customProperty(declaration) {
|
|
1663
|
+
const colon = declaration.indexOf(":");
|
|
1664
|
+
if (colon === -1) return void 0;
|
|
1665
|
+
const name = declaration.slice(0, colon).trim();
|
|
1666
|
+
const value = declaration.slice(colon + 1).trim();
|
|
1667
|
+
return name.length <= 100 && value.length <= 200 && /^--[\w-]+$/.test(name) && value ? [name, value] : void 0;
|
|
1668
|
+
}
|
|
1548
1669
|
function rootVariables(css) {
|
|
1549
1670
|
const vars = /* @__PURE__ */ new Map();
|
|
1550
|
-
const source = css
|
|
1551
|
-
const
|
|
1552
|
-
|
|
1553
|
-
|
|
1671
|
+
const source = stripComments(css);
|
|
1672
|
+
for (const [rawSelector, body] of cssBlocks(source)) {
|
|
1673
|
+
const selector = rawSelector.split(";").pop().trim();
|
|
1674
|
+
if (selector.length > 1e3) continue;
|
|
1554
1675
|
if (!/(?:^|[\s,]):root\b(?![^,]*\.dark)|^@theme\b/.test(selector) || /\.dark|\[data-theme/.test(selector)) continue;
|
|
1555
|
-
for (const declaration of
|
|
1556
|
-
const found = declaration
|
|
1557
|
-
if (found && !vars.has(found[
|
|
1676
|
+
for (const declaration of body.split(";")) {
|
|
1677
|
+
const found = customProperty(declaration);
|
|
1678
|
+
if (found && !vars.has(found[0])) vars.set(found[0], found[1]);
|
|
1558
1679
|
}
|
|
1559
1680
|
}
|
|
1560
1681
|
return vars;
|
|
1561
1682
|
}
|
|
1683
|
+
function stripComments(css) {
|
|
1684
|
+
let out = "";
|
|
1685
|
+
for (let at = 0; ; ) {
|
|
1686
|
+
const start = css.indexOf("/*", at);
|
|
1687
|
+
if (start === -1) return out + css.slice(at);
|
|
1688
|
+
out += css.slice(at, start);
|
|
1689
|
+
const end = css.indexOf("*/", start + 2);
|
|
1690
|
+
if (end === -1) return out;
|
|
1691
|
+
at = end + 2;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1562
1694
|
function resolve(value, vars, depth = 0) {
|
|
1563
1695
|
if (depth > 4) return void 0;
|
|
1564
1696
|
const reference = value.match(/^(?:(hsla?|oklch|rgba?)\(\s*)?var\(\s*(--[\w-]+)\s*(?:,[^)]*)?\)\s*\)?$/i);
|
|
@@ -1574,6 +1706,61 @@ function radiusOption(value) {
|
|
|
1574
1706
|
const rem = match[2] === "px" ? parseFloat(match[1]) / 16 : parseFloat(match[1]);
|
|
1575
1707
|
return rem < 0.525 ? "0.35" : rem < 0.9 ? "0.7" : "1.1";
|
|
1576
1708
|
}
|
|
1709
|
+
function saturationLightness(hex) {
|
|
1710
|
+
const [r, g, b] = [1, 3, 5].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255);
|
|
1711
|
+
const max = Math.max(r, g, b);
|
|
1712
|
+
const min = Math.min(r, g, b);
|
|
1713
|
+
const lightness = (max + min) / 2;
|
|
1714
|
+
const saturation = max === min ? 0 : (max - min) / (1 - Math.abs(2 * lightness - 1));
|
|
1715
|
+
return [saturation, lightness];
|
|
1716
|
+
}
|
|
1717
|
+
function isVivid(hex) {
|
|
1718
|
+
if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) return false;
|
|
1719
|
+
const [saturation, lightness] = saturationLightness(hex);
|
|
1720
|
+
return saturation >= 0.35 && lightness >= 0.2 && lightness <= 0.8;
|
|
1721
|
+
}
|
|
1722
|
+
var BUTTON_SELECTOR = /(?:^|[\s,>+~])(?:button|\.btn[\w-]*|\.button[\w-]*|\[type=["']?submit["']?\]|\.primary[\w-]*)(?![\w-])/i;
|
|
1723
|
+
var PAGE_SELECTOR = /^(?:html|body|:root)(?:\s*,\s*(?:html|body|:root))*$/i;
|
|
1724
|
+
function pageColors(css, vars) {
|
|
1725
|
+
const out = {};
|
|
1726
|
+
const source = stripComments(css);
|
|
1727
|
+
const declarations = (body) => body.split(";").filter((part) => part.length <= 300).map((part) => {
|
|
1728
|
+
const colon = part.indexOf(":");
|
|
1729
|
+
if (colon === -1) return void 0;
|
|
1730
|
+
let value = part.slice(colon + 1).trim();
|
|
1731
|
+
if (value.toLowerCase().endsWith("!important")) value = value.slice(0, -"!important".length).trim();
|
|
1732
|
+
return [part.slice(0, colon).trim().toLowerCase(), value];
|
|
1733
|
+
}).filter((entry) => Boolean(entry));
|
|
1734
|
+
const colorOf = (value) => resolve(value, vars) ?? value.split(/\s+(?![^(]*\))/).map((part) => resolve(part, vars)).find(Boolean);
|
|
1735
|
+
for (const [rawSelector, body] of cssBlocks(source)) {
|
|
1736
|
+
const selector = rawSelector.split(";").pop().trim();
|
|
1737
|
+
if (selector.length > 1e3 || /\.dark|\[data-theme|:hover|:active|:disabled|:focus/.test(selector)) continue;
|
|
1738
|
+
if (PAGE_SELECTOR.test(selector)) {
|
|
1739
|
+
for (const [property, value] of declarations(body)) {
|
|
1740
|
+
if (!out.background && (property === "background" || property === "background-color")) out.background = colorOf(value);
|
|
1741
|
+
if (!out.foreground && property === "color") out.foreground = colorOf(value);
|
|
1742
|
+
}
|
|
1743
|
+
} else if (!out.button && BUTTON_SELECTOR.test(selector) && !/ghost|outline|secondary|link|danger|red|cancel/i.test(selector)) {
|
|
1744
|
+
for (const [property, value] of declarations(body)) {
|
|
1745
|
+
if (property === "background" || property === "background-color" || /^--[\w-]*(?:bg|background)$/.test(property)) {
|
|
1746
|
+
const color = colorOf(value);
|
|
1747
|
+
if (isVivid(color)) {
|
|
1748
|
+
out.button = color;
|
|
1749
|
+
break;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1756
|
+
const seen = /var\(\s*(--[\w-]+)\s*[,)]/g;
|
|
1757
|
+
for (let match = seen.exec(source); match; match = seen.exec(source)) {
|
|
1758
|
+
const color = resolve("var(" + match[1] + ")", vars);
|
|
1759
|
+
if (isVivid(color)) counts.set(color, (counts.get(color) ?? 0) + 1);
|
|
1760
|
+
}
|
|
1761
|
+
out.mostUsed = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
1762
|
+
return out;
|
|
1763
|
+
}
|
|
1577
1764
|
var CSS_PRIORITY = [/(?:^|\/)app\/globals\.css$/, /(?:^|\/)styles\/globals\.css$/, /globals\.css$/, /\.css$/];
|
|
1578
1765
|
var PRIMARY_VARS = ["--primary", "--color-primary", "--brand", "--color-brand", "--accent", "--color-accent"];
|
|
1579
1766
|
function rank(path) {
|
|
@@ -1602,6 +1789,13 @@ function inferTheme(files, root = "") {
|
|
|
1602
1789
|
for (const name of ["--background", "--color-background"]) if (vars.has(name)) set("background", resolve(vars.get(name), vars), varPath.get(name));
|
|
1603
1790
|
for (const name of ["--foreground", "--color-foreground"]) if (vars.has(name)) set("foreground", resolve(vars.get(name), vars), varPath.get(name));
|
|
1604
1791
|
if (vars.has("--radius")) set("radius", radiusOption(vars.get("--radius")), varPath.get("--radius"));
|
|
1792
|
+
const guessedPrimary = [];
|
|
1793
|
+
for (const path of cssFiles) {
|
|
1794
|
+
const used = pageColors(files[path], vars);
|
|
1795
|
+
set("background", used.background, path);
|
|
1796
|
+
set("foreground", used.foreground, path);
|
|
1797
|
+
guessedPrimary.push([used.button ?? used.mostUsed, path]);
|
|
1798
|
+
}
|
|
1605
1799
|
for (const path of Object.keys(files).filter((p) => inRoot(p) && /(?:^|\/)tailwind\.config\.[cm]?[jt]s$/.test(p)).sort()) {
|
|
1606
1800
|
const source = files[path];
|
|
1607
1801
|
for (const key of ["primary", "brand", "accent"]) {
|
|
@@ -1623,10 +1817,241 @@ function inferTheme(files, root = "") {
|
|
|
1623
1817
|
} catch {
|
|
1624
1818
|
}
|
|
1625
1819
|
}
|
|
1820
|
+
for (const [color, path] of guessedPrimary) set("primary", color, path);
|
|
1626
1821
|
if (!theme.primary) return void 0;
|
|
1627
1822
|
return { theme: { ...theme, primary: theme.primary }, sources };
|
|
1628
1823
|
}
|
|
1824
|
+
|
|
1825
|
+
// src/conversation-log.ts
|
|
1826
|
+
function userLabel(user) {
|
|
1827
|
+
if (user.displayName?.trim()) return user.displayName.trim();
|
|
1828
|
+
if (user.email?.trim()) return user.email.trim();
|
|
1829
|
+
if (user.anonymous) return "Anonymous visitor";
|
|
1830
|
+
return "Signed-in user \xB7 " + shortId(user.id);
|
|
1831
|
+
}
|
|
1832
|
+
function shortId(id) {
|
|
1833
|
+
const plain = id.replace(/^[a-z]+[:_]/i, "");
|
|
1834
|
+
return plain.length > 8 ? plain.slice(0, 8) : plain;
|
|
1835
|
+
}
|
|
1836
|
+
function conversationLog(conversation, options = {}) {
|
|
1837
|
+
const { user, messages, agentMessages } = conversation;
|
|
1838
|
+
const tools = messages.flatMap(toolsOf);
|
|
1839
|
+
const failedTools = tools.filter((tool) => tool.status === "failed" || tool.status === "denied").length;
|
|
1840
|
+
const out = [];
|
|
1841
|
+
out.push("# Frankie conversation log", "");
|
|
1842
|
+
out.push(
|
|
1843
|
+
"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.",
|
|
1844
|
+
""
|
|
1845
|
+
);
|
|
1846
|
+
out.push("## Summary", "");
|
|
1847
|
+
out.push(`- Conversation: ${conversation.title || "Untitled"} (\`${conversation.id}\`)`);
|
|
1848
|
+
if (options.workspace) out.push(`- Workspace: ${options.workspace.name} (\`${options.workspace.id}\`)`);
|
|
1849
|
+
out.push(`- Person: ${userLabel(user)} (\`${user.id}\`, ${user.anonymous ? "anonymous" : "signed in"})`);
|
|
1850
|
+
if (options.agent) out.push(`- Agent: ${options.agent}`);
|
|
1851
|
+
out.push(`- Started: ${iso(conversation.createdAt)}`);
|
|
1852
|
+
out.push(`- Last active: ${iso(conversation.updatedAt)}`);
|
|
1853
|
+
out.push(`- Messages: ${messages.length} \xB7 Actions: ${tools.length}${failedTools ? ` (${failedTools} failed)` : ""}`);
|
|
1854
|
+
out.push(`- Cost: ${conversation.costUsd > 0 ? "$" + conversation.costUsd.toFixed(4) : "not reported"}`);
|
|
1855
|
+
out.push(`- State: ${conversation.busy ? "still responding" : "finished"}`);
|
|
1856
|
+
out.push("");
|
|
1857
|
+
const escalations = conversation.escalations ?? [];
|
|
1858
|
+
if (escalations.length) {
|
|
1859
|
+
out.push("## Handed to the team", "");
|
|
1860
|
+
for (const escalation of escalations) {
|
|
1861
|
+
const contact = [escalation.name, escalation.email].filter(Boolean).join(", ");
|
|
1862
|
+
out.push(`- ${iso(escalation.at)}: ${escalation.reason}${contact ? ` (left ${contact})` : ""}`);
|
|
1863
|
+
if (escalation.summary) out.push(` ${escalation.summary.replace(/\n+/g, " ")}`);
|
|
1864
|
+
}
|
|
1865
|
+
out.push("");
|
|
1866
|
+
}
|
|
1867
|
+
out.push("## Chat (what the person saw)", "");
|
|
1868
|
+
if (!messages.length) out.push("_The chat widget saved no messages for this conversation._", "");
|
|
1869
|
+
for (const message of messages) {
|
|
1870
|
+
const item = record(message);
|
|
1871
|
+
if (!item) continue;
|
|
1872
|
+
const role = str(item.role) ?? "message";
|
|
1873
|
+
const speaker = role === "user" ? `Person (${userLabel(user)})` : role === "assistant" ? options.agent ?? "Agent" : role;
|
|
1874
|
+
const at = num(item.createdAt);
|
|
1875
|
+
out.push(`### ${speaker}${at ? ` \xB7 ${iso(at)}` : ""}`, "");
|
|
1876
|
+
const text = textOf(item);
|
|
1877
|
+
if (text) out.push(clip(text), "");
|
|
1878
|
+
for (const file of list(item.attachments)) out.push(`_Attached: ${str(file.name) ?? "file"} (${str(file.mimeType) ?? "unknown type"})_`);
|
|
1879
|
+
const messageTools = toolsOf(item);
|
|
1880
|
+
if (messageTools.length) {
|
|
1881
|
+
out.push("Actions:");
|
|
1882
|
+
for (const tool of messageTools) {
|
|
1883
|
+
const detail = str(tool.error) ?? str(tool.detail);
|
|
1884
|
+
out.push(`- [${str(tool.status) ?? "done"}] ${str(tool.label) ?? str(tool.name) ?? "tool"}${tool.name ? ` (\`${str(tool.name)}\`)` : ""}${detail ? `: ${oneLine(detail, 300)}` : ""}`);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
const error = str(item.error);
|
|
1888
|
+
if (error || item.status === "error") out.push("", `**This reply didn't finish:** ${error ?? "no error was recorded"}`);
|
|
1889
|
+
out.push("");
|
|
1890
|
+
}
|
|
1891
|
+
out.push("## Agent trace (model messages, tool calls and results)", "");
|
|
1892
|
+
if (!agentMessages.length) out.push("_The server recorded no agent messages for this conversation._", "");
|
|
1893
|
+
agentMessages.forEach((message, index) => {
|
|
1894
|
+
const item = record(message);
|
|
1895
|
+
if (!item) return;
|
|
1896
|
+
const role = str(item.role) ?? "message";
|
|
1897
|
+
const at = num(item.timestamp);
|
|
1898
|
+
if (role === "toolResult" || role === "tool_result") {
|
|
1899
|
+
const failed = item.isError === true;
|
|
1900
|
+
out.push(`### ${index + 1}. Tool result \xB7 \`${str(item.toolName) ?? "tool"}\`${failed ? " \xB7 ERROR" : ""}${at ? ` \xB7 ${iso(at)}` : ""}`, "");
|
|
1901
|
+
out.push(fence(contentText(item.content)), "");
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
const heading = [`${index + 1}. ${role === "assistant" ? "Assistant" : role === "user" ? "User" : role}`];
|
|
1905
|
+
if (role === "assistant") {
|
|
1906
|
+
if (str(item.model)) heading.push(str(item.model));
|
|
1907
|
+
if (str(item.stopReason)) heading.push(`stop: ${str(item.stopReason)}`);
|
|
1908
|
+
}
|
|
1909
|
+
if (at) heading.push(iso(at));
|
|
1910
|
+
out.push(`### ${heading.join(" \xB7 ")}`, "");
|
|
1911
|
+
const errorMessage = str(item.errorMessage) ?? str(item.error);
|
|
1912
|
+
if (errorMessage) out.push(`**Error:** ${errorMessage}`, "");
|
|
1913
|
+
if (typeof item.content === "string") {
|
|
1914
|
+
out.push(clip(item.content), "");
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
const before = out.length;
|
|
1918
|
+
for (const block of list(item.content)) {
|
|
1919
|
+
const type = str(block.type);
|
|
1920
|
+
if (type === "text") {
|
|
1921
|
+
if (str(block.text)) out.push(clip(str(block.text)), "");
|
|
1922
|
+
} else if (type === "thinking") {
|
|
1923
|
+
if (str(block.thinking) && block.redacted !== true) out.push("Thinking:", fence(str(block.thinking)), "");
|
|
1924
|
+
} else if (type === "toolCall") out.push(`Tool call \`${str(block.name) ?? "tool"}\`${str(block.id) ? ` (id \`${str(block.id)}\`)` : ""}:`, fence(json(block.arguments ?? {}), "json"), "");
|
|
1925
|
+
else if (type === "image") out.push(`_[image: ${str(block.mimeType) ?? "image"}, not included]_`, "");
|
|
1926
|
+
else if (type) out.push(`${type}:`, fence(json(stripLarge(block)), "json"), "");
|
|
1927
|
+
}
|
|
1928
|
+
if (out.length === before && !errorMessage) out.push("_(empty: no text and no tool calls)_", "");
|
|
1929
|
+
});
|
|
1930
|
+
return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
1931
|
+
}
|
|
1932
|
+
function conversationLogFileName(conversation) {
|
|
1933
|
+
const slug = (conversation.title || "conversation").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "conversation";
|
|
1934
|
+
return `frankie-${slug}-${conversation.id.slice(0, 12)}.md`;
|
|
1935
|
+
}
|
|
1936
|
+
var BLOCK_LIMIT = 12e3;
|
|
1937
|
+
function record(value) {
|
|
1938
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
1939
|
+
}
|
|
1940
|
+
function list(value) {
|
|
1941
|
+
return Array.isArray(value) ? value.map(record).filter((item) => Boolean(item)) : [];
|
|
1942
|
+
}
|
|
1943
|
+
function str(value) {
|
|
1944
|
+
return typeof value === "string" && value.trim() ? value : void 0;
|
|
1945
|
+
}
|
|
1946
|
+
function num(value) {
|
|
1947
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
1948
|
+
}
|
|
1949
|
+
function iso(at) {
|
|
1950
|
+
const date = new Date(at);
|
|
1951
|
+
return Number.isNaN(date.getTime()) ? "unknown time" : date.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
1952
|
+
}
|
|
1953
|
+
function toolsOf(message) {
|
|
1954
|
+
return list(record(message)?.tools);
|
|
1955
|
+
}
|
|
1956
|
+
function textOf(item) {
|
|
1957
|
+
if (typeof item.text === "string") return item.text;
|
|
1958
|
+
if (typeof item.content === "string") return item.content;
|
|
1959
|
+
return contentText(item.content);
|
|
1960
|
+
}
|
|
1961
|
+
function contentText(content) {
|
|
1962
|
+
if (typeof content === "string") return content;
|
|
1963
|
+
return list(content).map((block) => block.type === "image" ? `[image: ${str(block.mimeType) ?? "image"}, not included]` : str(block.text) ?? "").filter(Boolean).join("\n");
|
|
1964
|
+
}
|
|
1965
|
+
function stripLarge(block) {
|
|
1966
|
+
const out = {};
|
|
1967
|
+
for (const [key, value] of Object.entries(block)) {
|
|
1968
|
+
if (/signature$/i.test(key)) continue;
|
|
1969
|
+
out[key] = typeof value === "string" && value.length > 2e3 && /^[A-Za-z0-9+/=]+$/.test(value) ? `[${value.length} characters of binary data]` : value;
|
|
1970
|
+
}
|
|
1971
|
+
return out;
|
|
1972
|
+
}
|
|
1973
|
+
function json(value) {
|
|
1974
|
+
try {
|
|
1975
|
+
return JSON.stringify(value, null, 2) ?? String(value);
|
|
1976
|
+
} catch {
|
|
1977
|
+
return String(value);
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
function clip(text, limit = BLOCK_LIMIT) {
|
|
1981
|
+
return text.length > limit ? `${text.slice(0, limit)}
|
|
1982
|
+
\u2026 [${text.length - limit} more characters cut]` : text;
|
|
1983
|
+
}
|
|
1984
|
+
function oneLine(text, limit) {
|
|
1985
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
1986
|
+
return flat.length > limit ? `${flat.slice(0, limit)}\u2026` : flat;
|
|
1987
|
+
}
|
|
1988
|
+
function fence(text, lang = "") {
|
|
1989
|
+
const body = clip(text || "(empty)");
|
|
1990
|
+
const longest = Math.max(2, ...Array.from(body.matchAll(/`+/g), (match) => match[0].length));
|
|
1991
|
+
const ticks = "`".repeat(longest + 1);
|
|
1992
|
+
return `${ticks}${lang}
|
|
1993
|
+
${body}
|
|
1994
|
+
${ticks}`;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// src/api-sync.ts
|
|
1998
|
+
var API_SYNC_WORKFLOW_PATH = ".github/workflows/frankie-api-sync.yml";
|
|
1999
|
+
function apiSyncWorkflow(options) {
|
|
2000
|
+
const { url, workspace, api, path } = options;
|
|
2001
|
+
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.");
|
|
2002
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/i.test(workspace)) throw new Error("Invalid workspace id.");
|
|
2003
|
+
if (!/^[\w.-]{1,80}$/.test(api)) throw new Error("Invalid API name.");
|
|
2004
|
+
if (!isSafeRepositoryPath(path)) throw new Error("Choose a JSON or YAML file in the repository, like public/openapi.json.");
|
|
2005
|
+
return template2.replaceAll("__PATH__", path).replaceAll("__URL__", url.replace(/\/+$/, "")).replaceAll("__WORKSPACE__", workspace).replaceAll("__API__", api);
|
|
2006
|
+
}
|
|
2007
|
+
function isSafeRepositoryPath(path) {
|
|
2008
|
+
return /^[\w@.-]+(?:\/[\w@.-]+)*\.(?:json|ya?ml)$/i.test(path) && !path.split("/").some((part) => part === ".." || part === ".");
|
|
2009
|
+
}
|
|
2010
|
+
var template2 = String.raw`# Managed by Frankie: keeps Frankie's copy of your API description in step.
|
|
2011
|
+
# frankie-api-sync: __WORKSPACE__/__API__
|
|
2012
|
+
# Runs when __PATH__ changes on the default branch. No secrets: GitHub's OIDC
|
|
2013
|
+
# token proves which repository and commit this is. New or changed actions
|
|
2014
|
+
# stay off in Frankie until you allow them.
|
|
2015
|
+
name: Frankie API sync
|
|
2016
|
+
on:
|
|
2017
|
+
push:
|
|
2018
|
+
paths:
|
|
2019
|
+
- "__PATH__"
|
|
2020
|
+
workflow_dispatch:
|
|
2021
|
+
permissions:
|
|
2022
|
+
contents: read
|
|
2023
|
+
id-token: write
|
|
2024
|
+
concurrency:
|
|
2025
|
+
group: frankie-api-sync
|
|
2026
|
+
cancel-in-progress: true
|
|
2027
|
+
jobs:
|
|
2028
|
+
sync:
|
|
2029
|
+
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
|
|
2030
|
+
runs-on: ubuntu-latest
|
|
2031
|
+
timeout-minutes: 5
|
|
2032
|
+
steps:
|
|
2033
|
+
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
|
|
2034
|
+
with:
|
|
2035
|
+
persist-credentials: false
|
|
2036
|
+
sparse-checkout: __PATH__
|
|
2037
|
+
sparse-checkout-cone-mode: false
|
|
2038
|
+
- name: Send the API description to Frankie
|
|
2039
|
+
env:
|
|
2040
|
+
FRANKIE_URL: "__URL__"
|
|
2041
|
+
FRANKIE_WORKSPACE: "__WORKSPACE__"
|
|
2042
|
+
FRANKIE_API: "__API__"
|
|
2043
|
+
SPEC_PATH: "__PATH__"
|
|
2044
|
+
run: |
|
|
2045
|
+
test -f "$SPEC_PATH"
|
|
2046
|
+
token="$(curl -sSf -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=frankie" | jq -r .value)"
|
|
2047
|
+
jq -n --arg workspace "$FRANKIE_WORKSPACE" --arg api "$FRANKIE_API" --arg path "$SPEC_PATH" --rawfile spec "$SPEC_PATH" \
|
|
2048
|
+
'{workspace: $workspace, api: $api, path: $path, spec: $spec}' > "$RUNNER_TEMP/frankie-api.json"
|
|
2049
|
+
curl -sS --fail-with-body -X POST "$FRANKIE_URL/github/api-sync" \
|
|
2050
|
+
-H "Authorization: Bearer $token" -H "content-type: application/json" \
|
|
2051
|
+
--data "@$RUNNER_TEMP/frankie-api.json"
|
|
2052
|
+
`;
|
|
1629
2053
|
export {
|
|
2054
|
+
API_SYNC_WORKFLOW_PATH,
|
|
1630
2055
|
FIREWORKS_MODELS,
|
|
1631
2056
|
SKILL_TOOL_NAME,
|
|
1632
2057
|
STANDARD_AGENT_IDS,
|
|
@@ -1637,12 +2062,15 @@ export {
|
|
|
1637
2062
|
apiMethods,
|
|
1638
2063
|
apiOperations,
|
|
1639
2064
|
apiSourceSchema,
|
|
2065
|
+
apiSyncWorkflow,
|
|
1640
2066
|
approvedSkillTools,
|
|
1641
2067
|
assertNoCommittedFrankieKeys,
|
|
1642
2068
|
authConfigSchema,
|
|
1643
2069
|
brandingConfigSchema,
|
|
1644
2070
|
canonicalJson,
|
|
1645
2071
|
compareApiDocuments,
|
|
2072
|
+
conversationLog,
|
|
2073
|
+
conversationLogFileName,
|
|
1646
2074
|
defineFrankieConfig,
|
|
1647
2075
|
describeTool,
|
|
1648
2076
|
draftSkill,
|
|
@@ -1653,7 +2081,9 @@ export {
|
|
|
1653
2081
|
insertImport,
|
|
1654
2082
|
installAgentIds,
|
|
1655
2083
|
installBranch,
|
|
2084
|
+
installPrompt,
|
|
1656
2085
|
installTitle,
|
|
2086
|
+
isSafeRepositoryPath,
|
|
1657
2087
|
knowledgeBaseSchema,
|
|
1658
2088
|
knowledgeSourceSchema,
|
|
1659
2089
|
limitsConfigSchema,
|
|
@@ -1670,6 +2100,7 @@ export {
|
|
|
1670
2100
|
operationId,
|
|
1671
2101
|
parseFrankieConfig,
|
|
1672
2102
|
personaEnvKey,
|
|
2103
|
+
personaInstructions,
|
|
1673
2104
|
personaProfileSchema,
|
|
1674
2105
|
plainText,
|
|
1675
2106
|
planHostedInstall,
|
|
@@ -1682,6 +2113,7 @@ export {
|
|
|
1682
2113
|
thinkingLevelSchema,
|
|
1683
2114
|
toolDescriptions,
|
|
1684
2115
|
toolPolicySchema,
|
|
2116
|
+
userLabel,
|
|
1685
2117
|
validateApiDocument,
|
|
1686
2118
|
widgetThemeSchema,
|
|
1687
2119
|
withDependencies
|