@frankieai/core 0.2.0 → 0.2.2

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
@@ -56,7 +56,7 @@ var skillSchema = z.object({
56
56
  function activeSkills(config, availableTools) {
57
57
  const approved = new Set(approvedSkillTools(config));
58
58
  const available = availableTools ? new Set(availableTools) : approved;
59
- return config.skills.filter((skill) => skill.status === "published" && skill.tools.every((tool) => approved.has(tool) && available.has(tool)));
59
+ return config.skills.filter((skill) => skill.status === "published").map((skill) => ({ ...skill, tools: skill.tools.filter((tool) => approved.has(tool) && available.has(tool)) })).filter((skill) => skill.tools.length > 0);
60
60
  }
61
61
  function approvedSkillTools(config) {
62
62
  const candidates = [
@@ -97,10 +97,16 @@ var modelConfigSchema = z2.object({
97
97
  */
98
98
  provider: z2.string().min(1).default("fireworks"),
99
99
  /**
100
- * Model id within that provider. The default is Kimi K2.7 on Fireworks,
101
- * which serves a native Anthropic-Messages endpoint and a 262K window.
100
+ * Model id within that provider. The default is Kimi K3 on Fireworks, served
101
+ * over the OpenAI-completions API. Crucially for an embedded chat widget, that
102
+ * endpoint returns the model's reasoning in a separate `reasoning_content`
103
+ * field (surfaced as a thinking stream the client gates off), rather than
104
+ * inlining it in the visible text channel — so the model's private planning
105
+ * never leaks into the user-facing reply. The earlier Anthropic-Messages Kimi
106
+ * builds emitted that reasoning as plain text on Fireworks, which no marker or
107
+ * prompt reliably suppressed.
102
108
  */
103
- model: z2.string().default("accounts/fireworks/models/kimi-k2p7-code"),
109
+ model: z2.string().default("accounts/fireworks/models/kimi-k3"),
104
110
  /** Env var holding the provider key. Never put the key in config itself. */
105
111
  apiKeyEnv: z2.string().default("FIREWORKS_API_KEY"),
106
112
  /** Override the provider's base URL (self-hosted gateways, tests). */
@@ -174,7 +180,16 @@ var apiCapabilitySchema = z2.object({
174
180
  operationId: z2.string().min(1),
175
181
  method: z2.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
176
182
  path: z2.string().min(1),
177
- summary: z2.string().optional()
183
+ summary: z2.string().optional(),
184
+ /**
185
+ * True when this is a write operation the spec requires auth for, but the
186
+ * source has no way to authorize it as the specific calling user — no
187
+ * forwarded user token or claim, only (at best) a static/shared credential.
188
+ * A configured `security` credential never clears this on its own: it
189
+ * authorizes every call the same way regardless of who is chatting, which
190
+ * is a backend gap to flag, not something a spec or credential change fixes.
191
+ */
192
+ authGap: z2.boolean().default(false)
178
193
  });
179
194
  var apiSourceSchema = z2.object({
180
195
  name: z2.string().min(1).regex(/^[a-z0-9][a-z0-9_-]*$/i, "API source names must be alphanumeric with - or _"),
@@ -213,6 +228,17 @@ var apiSourceSchema = z2.object({
213
228
  forwardUserTokenAs: z2.string().optional(),
214
229
  /** Extra claims from the user token to forward as `x-frankie-<claim>`. */
215
230
  forwardUserClaims: z2.array(z2.string()).default([]),
231
+ /**
232
+ * Credentials for the spec's own `components.securitySchemes`, keyed by
233
+ * scheme name. Frankie applies each per-operation `security` requirement at
234
+ * the location and with the prefix the scheme declares (an `apiKey` scheme
235
+ * goes to its named header/query/cookie; an `http` `bearer` or `oauth2`
236
+ * scheme becomes an `Authorization: Bearer` header; `http` `basic` takes a
237
+ * `user:pass` value and is base64-encoded automatically). Without this, a
238
+ * spec with per-operation auth falls back to the source's static `headers`
239
+ * for every operation, which is often wrong for at least some of them.
240
+ */
241
+ security: z2.record(z2.string(), z2.string()).default({}),
216
242
  /** Per-call timeout. */
217
243
  timeoutMs: z2.number().int().positive().default(3e4),
218
244
  /** Response bodies longer than this are truncated before the model sees them. */
@@ -298,8 +324,16 @@ var frankieConfigSchema = z2.object({
298
324
  /** Opt-in end-user chat capabilities (attachments, model picker). */
299
325
  features: featuresConfigSchema
300
326
  });
327
+ var LEAKY_MODEL_MIGRATION = {
328
+ "accounts/fireworks/models/kimi-k2p7-code": "accounts/fireworks/models/kimi-k3"
329
+ };
301
330
  function parseFrankieConfig(input) {
302
- return frankieConfigSchema.parse(input);
331
+ const config = frankieConfigSchema.parse(input);
332
+ if (config.model.provider === "fireworks") {
333
+ const migrated = LEAKY_MODEL_MIGRATION[config.model.model];
334
+ if (migrated) config.model.model = migrated;
335
+ }
336
+ return config;
303
337
  }
304
338
  function defineFrankieConfig(config) {
305
339
  return config;
@@ -337,7 +371,7 @@ function planHostedInstall(input) {
337
371
  const tokenPath = appRoot + "api/frankie/token/route.ts";
338
372
  const needsAuthSetup = !workspace.allowAnonymous && !files[tokenPath] && !firebase && !authJs;
339
373
  if (needsAuthSetup) notes.push("Connect getCurrentUser in the generated token endpoint to your server session. It rejects requests until configured.");
340
- pkg.dependencies = { ...pkg.dependencies, "@frankieai/react": pkg.dependencies["@frankieai/react"] ?? "^0.2.0", ...!workspace.allowAnonymous ? { jose: pkg.dependencies.jose ?? "^6.0.11" } : {} };
374
+ pkg.dependencies = { ...pkg.dependencies, "@frankieai/react": pkg.dependencies["@frankieai/react"] ?? "^0.2.1", ...!workspace.allowAnonymous ? { jose: pkg.dependencies.jose ?? "^6.0.11" } : {} };
341
375
  out.set(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
342
376
  const authProp = workspace.allowAnonymous ? "" : " getUserToken={getUserToken}";
343
377
  const shared = `import { FrankieProvider, FrankieWidget, FrankieChatPage } from "@frankieai/react";
@@ -444,29 +478,34 @@ export async function POST() {
444
478
  } catch {
445
479
  }
446
480
  }
481
+ const middlewareMatchers = nextMiddlewareMatchers(files, sourceRoot, root);
447
482
  const paths = {};
448
- if (!doc) for (const [file, content] of Object.entries(files)) {
483
+ let withheld = 0;
484
+ for (const [file, content] of Object.entries(files)) {
449
485
  if (/\.(test|spec)\.[tj]sx?$/.test(file)) continue;
450
486
  const nextRoute = file.match(/(?:^|\/)app(\/.*)\/route\.[tj]s$/);
451
487
  if (nextRoute) {
452
488
  const path = nextRoute[1].replace(/\/\([^/]+\)/g, "").replace(/\[([^\]]+)\]/g, "{$1}");
453
489
  if (path.includes("...") || /\/(?:auth|frankie)(?:\/|$)/.test(path)) continue;
454
- for (const method of ["GET"]) {
455
- if (new RegExp("export\\s+(?:(?:async\\s+)?function\\s+" + method + "\\b|(?:const|let)\\s+" + method + "\\s*=)").test(content))
456
- addInstallOperation(paths, method, path, file);
490
+ const guarded = matchesAnyMatcher(middlewareMatchers, path);
491
+ for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
492
+ if (new RegExp("export\\s+(?:(?:async\\s+)?function\\s+" + method + "\\b|(?:const|let)\\s+" + method + "\\s*=)").test(content)) {
493
+ if (!addInstallOperation(paths, method, path, file, guarded)) withheld += 1;
494
+ }
457
495
  }
458
496
  } else if (/\.[tj]s$/.test(file) && /(?:routes?|api)\//.test(file)) {
459
- for (const match of content.matchAll(/\b(?:app|router|fastify)\.(get|post|put|patch|delete)(?:\s*<[^;]{1,1600}?>)?\s*\(\s*["']([^"']+)["']/g))
460
- addInstallOperation(paths, match[1].toUpperCase(), match[2].replace(/:([A-Za-z0-9_]+)/g, "{$1}"), file);
497
+ 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))
498
+ if (!addInstallOperation(paths, match[1].toUpperCase(), match[2].replace(/:([A-Za-z0-9_]+)/g, "{$1}"), file, Boolean(match[3]))) withheld += 1;
461
499
  }
462
500
  }
463
- doc ??= { openapi: "3.0.3", info: { title: workspace.name + " API", version: "1.0.0", description: "Read-only generated route inventory. Review schemas, authorization, and the deployed origin before enabling tools." }, paths };
464
- doc = { ...doc, paths: Object.fromEntries(Object.entries(doc.paths).filter(([, item]) => item.get).map(([path, item]) => [path, { ...item.parameters ? { parameters: item.parameters } : {}, get: item.get }])) };
501
+ 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 };
502
+ for (const [path, item] of Object.entries(paths)) doc.paths[path] = { ...item, ...doc.paths[path] };
465
503
  const specPath = root + "public/frankie.openapi.json";
466
504
  const operationCount = Object.values(doc.paths).reduce((n, item) => n + Object.keys(item).filter((m) => /^(get|post|put|patch|delete)$/.test(m)).length, 0);
467
505
  out.set(specPath, JSON.stringify(doc, null, 2) + "\n");
468
506
  if (!operationCount) notes.push("No supported API routes detected. The OpenAPI draft has no operations; add the real schema before connecting tools.");
469
- notes.push("Generated OpenAPI is read-only (GET only). Add write operations only to a reviewed, user-authorized schema.");
507
+ if (withheld) notes.push(`Withheld ${withheld} newly discovered write operation(s) with no detected authorization guard (an Express/Fastify middleware argument, or a Next.js middleware.ts matcher covering the route). Add them to the spec yourself once they're protected.`);
508
+ notes.push("Generated OpenAPI includes read and write routes. Review schemas before approving tools; new connections still default to GET-only access.");
470
509
  notes.push("Verify the public spec URL is reachable without dashboard authentication before connecting it.");
471
510
  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.");
472
511
  const envPath = root + ".env.example";
@@ -480,7 +519,7 @@ Workspace: ${workspace.name}
480
519
  ${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.
481
520
  4. Confirm https://YOUR_APP/frankie.openapi.json is public. If your Next.js middleware protects all paths, explicitly bypass /frankie.openapi.json before connecting it.
482
521
  5. Open ${chatPath}. It is created inside the detected authenticated dashboard group when one exists; add its link to the host sidebar during review.
483
- 6. Connect https://YOUR_APP/frankie.openapi.json in Frankie \u2192 Tools & MCP. The generated document contains only read-only GET operations.
522
+ 6. Connect https://YOUR_APP/frankie.openapi.json in Frankie \u2192 Tools & MCP. The document includes read and write routes; approve tools and choose access limits separately.
484
523
  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.
485
524
  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.
486
525
 
@@ -492,8 +531,10 @@ ${notes.map((n) => "- " + n).join("\n")}
492
531
  assertNoCommittedFrankieKeys(planned);
493
532
  return { files: planned, root, chatPath, specPath, operationCount, needsAuthSetup, notes };
494
533
  }
495
- function addInstallOperation(paths, method, path, file) {
496
- if (method !== "GET" || path.includes("*") || !path.startsWith("/") || /\/(?:auth|frankie)(?:\/|$)/.test(path)) return;
534
+ var WRITE_METHODS = /* @__PURE__ */ new Set(["post", "put", "patch", "delete"]);
535
+ function addInstallOperation(paths, method, path, file, guarded) {
536
+ if (path.includes("*") || !path.startsWith("/") || /\/(?:auth|frankie)(?:\/|$)/.test(path)) return true;
537
+ if (WRITE_METHODS.has(method.toLowerCase()) && !guarded) return false;
497
538
  (paths[path] ??= {})[method.toLowerCase()] = {
498
539
  operationId: (method.toLowerCase() + "_" + path).replace(/[^a-zA-Z0-9_]+/g, "_"),
499
540
  summary: method + " " + path,
@@ -502,6 +543,32 @@ function addInstallOperation(paths, method, path, file) {
502
543
  responses: { "200": { description: "Successful response" } },
503
544
  "x-frankie-review-required": true
504
545
  };
546
+ return true;
547
+ }
548
+ function nextMiddlewareMatchers(files, sourceRoot, root) {
549
+ const candidates = [.../* @__PURE__ */ new Set([sourceRoot + "middleware.ts", sourceRoot + "middleware.js", root + "middleware.ts", root + "middleware.js"])];
550
+ for (const candidate of candidates) {
551
+ const content = files[candidate];
552
+ if (typeof content !== "string") continue;
553
+ const config = content.match(/export\s+const\s+config\s*=\s*\{([^}]*)\}/);
554
+ if (!config) continue;
555
+ const matcher = config[1].match(/matcher\s*:\s*(\[[^\]]*\]|["'`][^"'`]*["'`])/);
556
+ if (!matcher) return [];
557
+ const raw = matcher[1];
558
+ return raw.startsWith("[") ? [...raw.matchAll(/["'`]([^"'`]+)["'`]/g)].map((m) => m[1]) : [raw.slice(1, -1)];
559
+ }
560
+ return [];
561
+ }
562
+ function matchesAnyMatcher(matchers, path) {
563
+ return matchers.some((pattern) => {
564
+ if (/[()|]/.test(pattern)) return false;
565
+ const glob = pattern.replace(/:[A-Za-z0-9_]+\*/g, "**").replace(/:[A-Za-z0-9_]+\+?/g, "*");
566
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
567
+ const trailingDeep = escaped.endsWith("/**");
568
+ const body = trailingDeep ? escaped.slice(0, -3) : escaped;
569
+ const regexBody = body.split("**").map((chunk) => chunk.replace(/\*/g, "[^/]*")).join(".*") + (trailingDeep ? "(?:/.*)?" : "");
570
+ return new RegExp(`^${regexBody}$`).test(path);
571
+ });
505
572
  }
506
573
 
507
574
  // src/sdk-updates.ts
@@ -528,20 +595,20 @@ jobs:
528
595
  outputs:
529
596
  base: $GH steps.base.outputs.sha }}
530
597
  steps:
531
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
598
+ - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
532
599
  with:
533
600
  persist-credentials: false
534
601
  - id: base
535
602
  run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
536
- - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
603
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444
537
604
  with:
538
- node-version: 22
605
+ node-version: 24
539
606
  - name: Install pinned pnpm
540
607
  run: npm install --global --ignore-scripts pnpm@10.33.0
541
608
  - name: Prepare SDK and lockfile together
542
609
  run: |
543
610
  test -f pnpm-lock.yaml
544
- pnpm --recursive --include-workspace-root update '@frankieai/react@latest' '@frankieai/core@latest' '@frankieai/cli@latest' '@frankieai/server@latest' --lockfile-only --ignore-scripts --ignore-pnpmfile
611
+ pnpm --recursive --include-workspace-root update '@frankieai/react@latest' '@frankieai/core@latest' '@frankieai/cli@latest' '@frankieai/server@latest' --lockfile-only --ignore-scripts --ignore-pnpmfile --config.manage-package-manager-versions=false --config.package-manager-strict-version=false
545
612
  - name: Package allowlisted changes
546
613
  run: |
547
614
  node --input-type=module <<'NODE'
@@ -557,7 +624,7 @@ jobs:
557
624
  mkdirSync('frankie-update-artifact', {recursive:true});
558
625
  writeFileSync('frankie-update-artifact/changes.json', JSON.stringify(files));
559
626
  NODE
560
- - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
627
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
561
628
  with:
562
629
  name: frankie-sdk-update
563
630
  path: frankie-update-artifact/changes.json
@@ -571,11 +638,11 @@ jobs:
571
638
  pull-requests: write
572
639
  steps:
573
640
  # Separate runner: no checkout, repository hooks, install scripts, or builds.
574
- - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
641
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
575
642
  with:
576
643
  name: frankie-sdk-update
577
644
  path: artifact
578
- - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
645
+ - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
579
646
  env:
580
647
  BASE_SHA: $GH needs.prepare.outputs.base }}
581
648
  with:
@@ -592,6 +659,7 @@ jobs:
592
659
  if (current.data.object.sha !== base) throw Error('Default branch changed. Run the updater again.');
593
660
  const existing = await github.paginate(github.rest.pulls.list, {owner,repo,state:'open',per_page:100});
594
661
  if (existing.some(pr => pr.head.repo?.full_name === owner + '/' + repo && pr.head.ref.startsWith('frankie/sdk-update-'))) { core.notice('An SDK update PR already exists; review it first.'); return; }
662
+ const canonical = value => Array.isArray(value) ? '[' + value.map(canonical).join(',') + ']' : value && typeof value === 'object' ? '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + canonical(value[key])).join(',') + '}' : JSON.stringify(value);
595
663
  const clean = value => {
596
664
  const copy = JSON.parse(JSON.stringify(value));
597
665
  for (const group of ['dependencies','devDependencies','optionalDependencies']) {
@@ -599,6 +667,7 @@ jobs:
599
667
  }
600
668
  return copy;
601
669
  };
670
+ let sdkChanged = false;
602
671
  const seen = new Set();
603
672
  for (const file of files) {
604
673
  if (!file || typeof file.path !== 'string' || typeof file.content !== 'string' || seen.has(file.path)) throw Error('Invalid update file');
@@ -609,14 +678,16 @@ jobs:
609
678
  const original = await github.rest.repos.getContent({owner,repo,path:file.path,ref:base});
610
679
  const before = JSON.parse(Buffer.from(original.data.content, 'base64').toString());
611
680
  const after = JSON.parse(file.content);
612
- if (JSON.stringify(clean(before)) !== JSON.stringify(clean(after))) throw Error('Non-SDK manifest change');
681
+ if (canonical(clean(before)) !== canonical(clean(after))) throw Error('Non-SDK manifest change in ' + file.path);
613
682
  for (const group of ['dependencies','devDependencies','optionalDependencies']) {
614
683
  const sdkKeys = value => Object.keys(value[group] || {}).filter(key => key.startsWith('@frankieai/')).sort();
615
684
  if (JSON.stringify(sdkKeys(before)) !== JSON.stringify(sdkKeys(after))) throw Error('SDK dependency added or removed');
685
+ if (sdkKeys(after).some(key => before[group][key] !== after[group][key])) sdkChanged = true;
616
686
  for (const [key, value] of Object.entries(after[group] || {})) if (key.startsWith('@frankieai/') && !/^[~^]?\d+\.\d+\.\d+$/.test(value)) throw Error('Only stable SDK releases are supported');
617
687
  }
618
688
  }
619
689
  }
690
+ if (!sdkChanged) { core.notice('Frankie SDK versions are unchanged; no formatting-only update PR is needed.'); return; }
620
691
  if (!seen.has('pnpm-lock.yaml')) throw Error('SDK update must include the lockfile');
621
692
  const parent = await github.rest.git.getCommit({owner,repo,commit_sha:base});
622
693
  const tree = await github.rest.git.createTree({owner,repo,base_tree:parent.data.tree.sha,tree:files.map(f => ({...f,mode:'100644',type:'blob'}))});
@@ -626,15 +697,131 @@ jobs:
626
697
  await github.rest.git.createRef({owner,repo,ref:'refs/heads/' + branch,sha:commit.data.sha});
627
698
  await github.rest.pulls.create({owner,repo,head:branch,base:context.payload.repository.default_branch,draft:true,title:'Update Frankie SDK',body:'Updates the SDK and pnpm lockfile together. No auto-merge. Review release notes and breaking changes, run CI, and test signed-in chat and tool authorization. PRs made with GITHUB_TOKEN may need a maintainer to trigger CI; this workflow does not bypass that protection.'});
628
699
  `;
700
+
701
+ // src/openapi-review.ts
702
+ var apiMethods = ["get", "post", "put", "patch", "delete", "head", "options", "trace"];
703
+ function canonicalJson(value) {
704
+ if (Array.isArray(value)) return "[" + value.map(canonicalJson).join(",") + "]";
705
+ if (value && typeof value === "object") return "{" + Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => JSON.stringify(k) + ":" + canonicalJson(v)).join(",") + "}";
706
+ return JSON.stringify(value) ?? "null";
707
+ }
708
+ function validateApiDocument(value) {
709
+ const doc = value;
710
+ if (!doc || typeof doc !== "object" || !/^3\.(0|1)\./.test(doc.openapi) || !doc.paths || typeof doc.paths !== "object" || Array.isArray(doc.paths)) throw new Error("Use an OpenAPI 3.0 or 3.1 document with a paths object.");
711
+ const ids = /* @__PURE__ */ new Set();
712
+ const walk = (node, depth = 0) => {
713
+ if (depth > 80) throw new Error("Spec nesting is too deep.");
714
+ if (!node || typeof node !== "object") return;
715
+ for (const [key, item] of Object.entries(node)) {
716
+ if (["__proto__", "prototype", "constructor"].includes(key)) throw new Error("Spec contains an unsafe property.");
717
+ if (key === "$ref" && (typeof item !== "string" || !item.startsWith("#/"))) throw new Error("Bundle external references into this spec before refreshing.");
718
+ walk(item, depth + 1);
719
+ }
720
+ };
721
+ walk(doc);
722
+ for (const [path, item] of Object.entries(doc.paths)) {
723
+ if (!path.startsWith("/") || !item || typeof item !== "object" || Array.isArray(item) || item.$ref) throw new Error("Use inline path items with absolute API paths.");
724
+ for (const method of apiMethods) {
725
+ const op = item[method];
726
+ if (!op) continue;
727
+ if (typeof op !== "object" || Array.isArray(op)) throw new Error("Invalid API operation.");
728
+ const id = operationId(method, path, op);
729
+ const toolId = id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 48);
730
+ if (ids.has(toolId)) throw new Error("Operation IDs must be unique, including their tool-safe names.");
731
+ ids.add(toolId);
732
+ }
733
+ }
734
+ }
735
+ function operationId(method, path, op) {
736
+ return typeof op.operationId === "string" ? op.operationId : `${method}_${path.replace(/[{}]/g, "").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_|_$/g, "")}`;
737
+ }
738
+ function resolvePointer(doc, ref) {
739
+ let current = doc;
740
+ for (const segment of ref.slice(2).split("/")) {
741
+ if (!current || typeof current !== "object") return void 0;
742
+ current = current[segment.replace(/~1/g, "/").replace(/~0/g, "~")];
743
+ }
744
+ return current;
745
+ }
746
+ function reachableDefinitions(doc, roots) {
747
+ const resolved = {};
748
+ const visit = (node) => {
749
+ if (Array.isArray(node)) {
750
+ for (const entry of node) visit(entry);
751
+ return;
752
+ }
753
+ if (!node || typeof node !== "object") return;
754
+ for (const [key, value] of Object.entries(node)) {
755
+ if (key === "$ref" && typeof value === "string") {
756
+ if (value in resolved) continue;
757
+ const target = resolvePointer(doc, value);
758
+ resolved[value] = target ?? null;
759
+ visit(target);
760
+ continue;
761
+ }
762
+ visit(value);
763
+ }
764
+ };
765
+ for (const root of roots) visit(root);
766
+ return resolved;
767
+ }
768
+ function apiOperations(doc) {
769
+ const operations = /* @__PURE__ */ new Map();
770
+ for (const [path, item] of Object.entries(doc.paths)) {
771
+ const parameters = Object.fromEntries(Object.entries(item).filter(([key]) => !apiMethods.includes(key)));
772
+ for (const method of apiMethods) {
773
+ const operation = item[method];
774
+ if (!operation) continue;
775
+ operations.set(method.toUpperCase() + " " + path, {
776
+ id: operationId(method, path, operation),
777
+ // Fingerprint what the operation's contract actually depends on: itself,
778
+ // the path item's shared fields, the definitions it reaches by `$ref`, and
779
+ // the document-level `security` that applies only where it declares none.
780
+ // Folding in the whole document instead meant one unrelated schema — or an
781
+ // `openapi` version bump — revoked every approval on the source.
782
+ // Document-level `servers` stays out deliberately: a refresh whose servers
783
+ // moved is already rejected unless the operator pinned an explicit baseUrl,
784
+ // and that override wins over the spec anyway (see agent/openapi.ts).
785
+ fingerprint: canonicalJson({
786
+ operation,
787
+ parameters,
788
+ security: operation["security"] === void 0 ? doc.security ?? null : null,
789
+ definitions: reachableDefinitions(doc, [operation, parameters])
790
+ })
791
+ });
792
+ }
793
+ }
794
+ return operations;
795
+ }
796
+ function matchPath(glob, path) {
797
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
798
+ const trailingDeep = escaped.endsWith("/**");
799
+ const body = trailingDeep ? escaped.slice(0, -3) : escaped;
800
+ const pattern = body.split("**").map((chunk) => chunk.replace(/\*/g, "[^/]*")).join(".*") + (trailingDeep ? "(?:/.*)?" : "");
801
+ return new RegExp(`^${pattern}$`).test(path);
802
+ }
803
+ function compareApiDocuments(before, after) {
804
+ const old = apiOperations(before), next = apiOperations(after);
805
+ return {
806
+ added: [...next.keys()].filter((key) => !old.has(key)),
807
+ changed: [...next.keys()].filter((key) => old.has(key) && old.get(key).fingerprint !== next.get(key).fingerprint),
808
+ removed: [...old.keys()].filter((key) => !next.has(key)),
809
+ unchangedIds: [...next].filter(([key, value]) => old.get(key)?.fingerprint === value.fingerprint).map(([, value]) => value.id)
810
+ };
811
+ }
629
812
  export {
630
813
  activeSkills,
631
814
  agentConfigSchema,
632
815
  apiCapabilitySchema,
816
+ apiMethods,
817
+ apiOperations,
633
818
  apiSourceSchema,
634
819
  approvedSkillTools,
635
820
  assertNoCommittedFrankieKeys,
636
821
  authConfigSchema,
637
822
  brandingConfigSchema,
823
+ canonicalJson,
824
+ compareApiDocuments,
638
825
  defineFrankieConfig,
639
826
  describeTool,
640
827
  draftSkill,
@@ -642,15 +829,18 @@ export {
642
829
  featuresConfigSchema,
643
830
  frankieConfigSchema,
644
831
  limitsConfigSchema,
832
+ matchPath,
645
833
  mcpServerSchema,
646
834
  modelChoiceSchema,
647
835
  modelConfigSchema,
836
+ operationId,
648
837
  parseFrankieConfig,
649
838
  planHostedInstall,
650
839
  sdkUpdateWorkflow,
651
840
  skillSchema,
652
841
  thinkingLevelSchema,
653
842
  toolPolicySchema,
843
+ validateApiDocument,
654
844
  widgetThemeSchema
655
845
  };
656
846
  //# sourceMappingURL=index.js.map