@frankieai/core 0.2.1 → 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.cjs CHANGED
@@ -39,6 +39,7 @@ __export(index_exports, {
39
39
  featuresConfigSchema: () => featuresConfigSchema,
40
40
  frankieConfigSchema: () => frankieConfigSchema,
41
41
  limitsConfigSchema: () => limitsConfigSchema,
42
+ matchPath: () => matchPath,
42
43
  mcpServerSchema: () => mcpServerSchema,
43
44
  modelChoiceSchema: () => modelChoiceSchema,
44
45
  modelConfigSchema: () => modelConfigSchema,
@@ -153,10 +154,16 @@ var modelConfigSchema = import_zod2.z.object({
153
154
  */
154
155
  provider: import_zod2.z.string().min(1).default("fireworks"),
155
156
  /**
156
- * Model id within that provider. The default is Kimi K2.7 on Fireworks,
157
- * which serves a native Anthropic-Messages endpoint and a 262K window.
157
+ * Model id within that provider. The default is Kimi K3 on Fireworks, served
158
+ * over the OpenAI-completions API. Crucially for an embedded chat widget, that
159
+ * endpoint returns the model's reasoning in a separate `reasoning_content`
160
+ * field (surfaced as a thinking stream the client gates off), rather than
161
+ * inlining it in the visible text channel — so the model's private planning
162
+ * never leaks into the user-facing reply. The earlier Anthropic-Messages Kimi
163
+ * builds emitted that reasoning as plain text on Fireworks, which no marker or
164
+ * prompt reliably suppressed.
158
165
  */
159
- model: import_zod2.z.string().default("accounts/fireworks/models/kimi-k2p7-code"),
166
+ model: import_zod2.z.string().default("accounts/fireworks/models/kimi-k3"),
160
167
  /** Env var holding the provider key. Never put the key in config itself. */
161
168
  apiKeyEnv: import_zod2.z.string().default("FIREWORKS_API_KEY"),
162
169
  /** Override the provider's base URL (self-hosted gateways, tests). */
@@ -230,7 +237,16 @@ var apiCapabilitySchema = import_zod2.z.object({
230
237
  operationId: import_zod2.z.string().min(1),
231
238
  method: import_zod2.z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
232
239
  path: import_zod2.z.string().min(1),
233
- summary: import_zod2.z.string().optional()
240
+ summary: import_zod2.z.string().optional(),
241
+ /**
242
+ * True when this is a write operation the spec requires auth for, but the
243
+ * source has no way to authorize it as the specific calling user — no
244
+ * forwarded user token or claim, only (at best) a static/shared credential.
245
+ * A configured `security` credential never clears this on its own: it
246
+ * authorizes every call the same way regardless of who is chatting, which
247
+ * is a backend gap to flag, not something a spec or credential change fixes.
248
+ */
249
+ authGap: import_zod2.z.boolean().default(false)
234
250
  });
235
251
  var apiSourceSchema = import_zod2.z.object({
236
252
  name: import_zod2.z.string().min(1).regex(/^[a-z0-9][a-z0-9_-]*$/i, "API source names must be alphanumeric with - or _"),
@@ -269,6 +285,17 @@ var apiSourceSchema = import_zod2.z.object({
269
285
  forwardUserTokenAs: import_zod2.z.string().optional(),
270
286
  /** Extra claims from the user token to forward as `x-frankie-<claim>`. */
271
287
  forwardUserClaims: import_zod2.z.array(import_zod2.z.string()).default([]),
288
+ /**
289
+ * Credentials for the spec's own `components.securitySchemes`, keyed by
290
+ * scheme name. Frankie applies each per-operation `security` requirement at
291
+ * the location and with the prefix the scheme declares (an `apiKey` scheme
292
+ * goes to its named header/query/cookie; an `http` `bearer` or `oauth2`
293
+ * scheme becomes an `Authorization: Bearer` header; `http` `basic` takes a
294
+ * `user:pass` value and is base64-encoded automatically). Without this, a
295
+ * spec with per-operation auth falls back to the source's static `headers`
296
+ * for every operation, which is often wrong for at least some of them.
297
+ */
298
+ security: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.string()).default({}),
272
299
  /** Per-call timeout. */
273
300
  timeoutMs: import_zod2.z.number().int().positive().default(3e4),
274
301
  /** Response bodies longer than this are truncated before the model sees them. */
@@ -354,8 +381,16 @@ var frankieConfigSchema = import_zod2.z.object({
354
381
  /** Opt-in end-user chat capabilities (attachments, model picker). */
355
382
  features: featuresConfigSchema
356
383
  });
384
+ var LEAKY_MODEL_MIGRATION = {
385
+ "accounts/fireworks/models/kimi-k2p7-code": "accounts/fireworks/models/kimi-k3"
386
+ };
357
387
  function parseFrankieConfig(input) {
358
- return frankieConfigSchema.parse(input);
388
+ const config = frankieConfigSchema.parse(input);
389
+ if (config.model.provider === "fireworks") {
390
+ const migrated = LEAKY_MODEL_MIGRATION[config.model.model];
391
+ if (migrated) config.model.model = migrated;
392
+ }
393
+ return config;
359
394
  }
360
395
  function defineFrankieConfig(config) {
361
396
  return config;
@@ -500,20 +535,24 @@ export async function POST() {
500
535
  } catch {
501
536
  }
502
537
  }
538
+ const middlewareMatchers = nextMiddlewareMatchers(files, sourceRoot, root);
503
539
  const paths = {};
540
+ let withheld = 0;
504
541
  for (const [file, content] of Object.entries(files)) {
505
542
  if (/\.(test|spec)\.[tj]sx?$/.test(file)) continue;
506
543
  const nextRoute = file.match(/(?:^|\/)app(\/.*)\/route\.[tj]s$/);
507
544
  if (nextRoute) {
508
545
  const path = nextRoute[1].replace(/\/\([^/]+\)/g, "").replace(/\[([^\]]+)\]/g, "{$1}");
509
546
  if (path.includes("...") || /\/(?:auth|frankie)(?:\/|$)/.test(path)) continue;
547
+ const guarded = matchesAnyMatcher(middlewareMatchers, path);
510
548
  for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
511
- if (new RegExp("export\\s+(?:(?:async\\s+)?function\\s+" + method + "\\b|(?:const|let)\\s+" + method + "\\s*=)").test(content))
512
- addInstallOperation(paths, method, path, file);
549
+ if (new RegExp("export\\s+(?:(?:async\\s+)?function\\s+" + method + "\\b|(?:const|let)\\s+" + method + "\\s*=)").test(content)) {
550
+ if (!addInstallOperation(paths, method, path, file, guarded)) withheld += 1;
551
+ }
513
552
  }
514
553
  } else if (/\.[tj]s$/.test(file) && /(?:routes?|api)\//.test(file)) {
515
- for (const match of content.matchAll(/\b(?:app|router|fastify)\.(get|post|put|patch|delete)(?:\s*<[^;]{1,1600}?>)?\s*\(\s*["']([^"']+)["']/g))
516
- addInstallOperation(paths, match[1].toUpperCase(), match[2].replace(/:([A-Za-z0-9_]+)/g, "{$1}"), file);
554
+ 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))
555
+ if (!addInstallOperation(paths, match[1].toUpperCase(), match[2].replace(/:([A-Za-z0-9_]+)/g, "{$1}"), file, Boolean(match[3]))) withheld += 1;
517
556
  }
518
557
  }
519
558
  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 };
@@ -522,6 +561,7 @@ export async function POST() {
522
561
  const operationCount = Object.values(doc.paths).reduce((n, item) => n + Object.keys(item).filter((m) => /^(get|post|put|patch|delete)$/.test(m)).length, 0);
523
562
  out.set(specPath, JSON.stringify(doc, null, 2) + "\n");
524
563
  if (!operationCount) notes.push("No supported API routes detected. The OpenAPI draft has no operations; add the real schema before connecting tools.");
564
+ 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.`);
525
565
  notes.push("Generated OpenAPI includes read and write routes. Review schemas before approving tools; new connections still default to GET-only access.");
526
566
  notes.push("Verify the public spec URL is reachable without dashboard authentication before connecting it.");
527
567
  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.");
@@ -548,8 +588,10 @@ ${notes.map((n) => "- " + n).join("\n")}
548
588
  assertNoCommittedFrankieKeys(planned);
549
589
  return { files: planned, root, chatPath, specPath, operationCount, needsAuthSetup, notes };
550
590
  }
551
- function addInstallOperation(paths, method, path, file) {
552
- if (path.includes("*") || !path.startsWith("/") || /\/(?:auth|frankie)(?:\/|$)/.test(path)) return;
591
+ var WRITE_METHODS = /* @__PURE__ */ new Set(["post", "put", "patch", "delete"]);
592
+ function addInstallOperation(paths, method, path, file, guarded) {
593
+ if (path.includes("*") || !path.startsWith("/") || /\/(?:auth|frankie)(?:\/|$)/.test(path)) return true;
594
+ if (WRITE_METHODS.has(method.toLowerCase()) && !guarded) return false;
553
595
  (paths[path] ??= {})[method.toLowerCase()] = {
554
596
  operationId: (method.toLowerCase() + "_" + path).replace(/[^a-zA-Z0-9_]+/g, "_"),
555
597
  summary: method + " " + path,
@@ -558,6 +600,32 @@ function addInstallOperation(paths, method, path, file) {
558
600
  responses: { "200": { description: "Successful response" } },
559
601
  "x-frankie-review-required": true
560
602
  };
603
+ return true;
604
+ }
605
+ function nextMiddlewareMatchers(files, sourceRoot, root) {
606
+ const candidates = [.../* @__PURE__ */ new Set([sourceRoot + "middleware.ts", sourceRoot + "middleware.js", root + "middleware.ts", root + "middleware.js"])];
607
+ for (const candidate of candidates) {
608
+ const content = files[candidate];
609
+ if (typeof content !== "string") continue;
610
+ const config = content.match(/export\s+const\s+config\s*=\s*\{([^}]*)\}/);
611
+ if (!config) continue;
612
+ const matcher = config[1].match(/matcher\s*:\s*(\[[^\]]*\]|["'`][^"'`]*["'`])/);
613
+ if (!matcher) return [];
614
+ const raw = matcher[1];
615
+ return raw.startsWith("[") ? [...raw.matchAll(/["'`]([^"'`]+)["'`]/g)].map((m) => m[1]) : [raw.slice(1, -1)];
616
+ }
617
+ return [];
618
+ }
619
+ function matchesAnyMatcher(matchers, path) {
620
+ return matchers.some((pattern) => {
621
+ if (/[()|]/.test(pattern)) return false;
622
+ const glob = pattern.replace(/:[A-Za-z0-9_]+\*/g, "**").replace(/:[A-Za-z0-9_]+\+?/g, "*");
623
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
624
+ const trailingDeep = escaped.endsWith("/**");
625
+ const body = trailingDeep ? escaped.slice(0, -3) : escaped;
626
+ const regexBody = body.split("**").map((chunk) => chunk.replace(/\*/g, "[^/]*")).join(".*") + (trailingDeep ? "(?:/.*)?" : "");
627
+ return new RegExp(`^${regexBody}$`).test(path);
628
+ });
561
629
  }
562
630
 
563
631
  // src/sdk-updates.ts
@@ -584,20 +652,20 @@ jobs:
584
652
  outputs:
585
653
  base: $GH steps.base.outputs.sha }}
586
654
  steps:
587
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
655
+ - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
588
656
  with:
589
657
  persist-credentials: false
590
658
  - id: base
591
659
  run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
592
- - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
660
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444
593
661
  with:
594
- node-version: 22
662
+ node-version: 24
595
663
  - name: Install pinned pnpm
596
664
  run: npm install --global --ignore-scripts pnpm@10.33.0
597
665
  - name: Prepare SDK and lockfile together
598
666
  run: |
599
667
  test -f pnpm-lock.yaml
600
- pnpm --recursive --include-workspace-root update '@frankieai/react@latest' '@frankieai/core@latest' '@frankieai/cli@latest' '@frankieai/server@latest' --lockfile-only --ignore-scripts --ignore-pnpmfile
668
+ 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
601
669
  - name: Package allowlisted changes
602
670
  run: |
603
671
  node --input-type=module <<'NODE'
@@ -613,7 +681,7 @@ jobs:
613
681
  mkdirSync('frankie-update-artifact', {recursive:true});
614
682
  writeFileSync('frankie-update-artifact/changes.json', JSON.stringify(files));
615
683
  NODE
616
- - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
684
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
617
685
  with:
618
686
  name: frankie-sdk-update
619
687
  path: frankie-update-artifact/changes.json
@@ -627,11 +695,11 @@ jobs:
627
695
  pull-requests: write
628
696
  steps:
629
697
  # Separate runner: no checkout, repository hooks, install scripts, or builds.
630
- - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
698
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
631
699
  with:
632
700
  name: frankie-sdk-update
633
701
  path: artifact
634
- - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
702
+ - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
635
703
  env:
636
704
  BASE_SHA: $GH needs.prepare.outputs.base }}
637
705
  with:
@@ -648,6 +716,7 @@ jobs:
648
716
  if (current.data.object.sha !== base) throw Error('Default branch changed. Run the updater again.');
649
717
  const existing = await github.paginate(github.rest.pulls.list, {owner,repo,state:'open',per_page:100});
650
718
  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; }
719
+ 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);
651
720
  const clean = value => {
652
721
  const copy = JSON.parse(JSON.stringify(value));
653
722
  for (const group of ['dependencies','devDependencies','optionalDependencies']) {
@@ -655,6 +724,7 @@ jobs:
655
724
  }
656
725
  return copy;
657
726
  };
727
+ let sdkChanged = false;
658
728
  const seen = new Set();
659
729
  for (const file of files) {
660
730
  if (!file || typeof file.path !== 'string' || typeof file.content !== 'string' || seen.has(file.path)) throw Error('Invalid update file');
@@ -665,14 +735,16 @@ jobs:
665
735
  const original = await github.rest.repos.getContent({owner,repo,path:file.path,ref:base});
666
736
  const before = JSON.parse(Buffer.from(original.data.content, 'base64').toString());
667
737
  const after = JSON.parse(file.content);
668
- if (JSON.stringify(clean(before)) !== JSON.stringify(clean(after))) throw Error('Non-SDK manifest change');
738
+ if (canonical(clean(before)) !== canonical(clean(after))) throw Error('Non-SDK manifest change in ' + file.path);
669
739
  for (const group of ['dependencies','devDependencies','optionalDependencies']) {
670
740
  const sdkKeys = value => Object.keys(value[group] || {}).filter(key => key.startsWith('@frankieai/')).sort();
671
741
  if (JSON.stringify(sdkKeys(before)) !== JSON.stringify(sdkKeys(after))) throw Error('SDK dependency added or removed');
742
+ if (sdkKeys(after).some(key => before[group][key] !== after[group][key])) sdkChanged = true;
672
743
  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');
673
744
  }
674
745
  }
675
746
  }
747
+ if (!sdkChanged) { core.notice('Frankie SDK versions are unchanged; no formatting-only update PR is needed.'); return; }
676
748
  if (!seen.has('pnpm-lock.yaml')) throw Error('SDK update must include the lockfile');
677
749
  const parent = await github.rest.git.getCommit({owner,repo,commit_sha:base});
678
750
  const tree = await github.rest.git.createTree({owner,repo,base_tree:parent.data.tree.sha,tree:files.map(f => ({...f,mode:'100644',type:'blob'}))});
@@ -720,15 +792,71 @@ function validateApiDocument(value) {
720
792
  function operationId(method, path, op) {
721
793
  return typeof op.operationId === "string" ? op.operationId : `${method}_${path.replace(/[{}]/g, "").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_|_$/g, "")}`;
722
794
  }
795
+ function resolvePointer(doc, ref) {
796
+ let current = doc;
797
+ for (const segment of ref.slice(2).split("/")) {
798
+ if (!current || typeof current !== "object") return void 0;
799
+ current = current[segment.replace(/~1/g, "/").replace(/~0/g, "~")];
800
+ }
801
+ return current;
802
+ }
803
+ function reachableDefinitions(doc, roots) {
804
+ const resolved = {};
805
+ const visit = (node) => {
806
+ if (Array.isArray(node)) {
807
+ for (const entry of node) visit(entry);
808
+ return;
809
+ }
810
+ if (!node || typeof node !== "object") return;
811
+ for (const [key, value] of Object.entries(node)) {
812
+ if (key === "$ref" && typeof value === "string") {
813
+ if (value in resolved) continue;
814
+ const target = resolvePointer(doc, value);
815
+ resolved[value] = target ?? null;
816
+ visit(target);
817
+ continue;
818
+ }
819
+ visit(value);
820
+ }
821
+ };
822
+ for (const root of roots) visit(root);
823
+ return resolved;
824
+ }
723
825
  function apiOperations(doc) {
724
826
  const operations = /* @__PURE__ */ new Map();
725
- const { paths, info: _info, "x-frankie-review": _review, ...shared } = doc;
726
- for (const [path, item] of Object.entries(paths)) {
827
+ for (const [path, item] of Object.entries(doc.paths)) {
727
828
  const parameters = Object.fromEntries(Object.entries(item).filter(([key]) => !apiMethods.includes(key)));
728
- for (const method of apiMethods) if (item[method]) operations.set(method.toUpperCase() + " " + path, { id: operationId(method, path, item[method]), fingerprint: canonicalJson({ shared, parameters, operation: item[method] }) });
829
+ for (const method of apiMethods) {
830
+ const operation = item[method];
831
+ if (!operation) continue;
832
+ operations.set(method.toUpperCase() + " " + path, {
833
+ id: operationId(method, path, operation),
834
+ // Fingerprint what the operation's contract actually depends on: itself,
835
+ // the path item's shared fields, the definitions it reaches by `$ref`, and
836
+ // the document-level `security` that applies only where it declares none.
837
+ // Folding in the whole document instead meant one unrelated schema — or an
838
+ // `openapi` version bump — revoked every approval on the source.
839
+ // Document-level `servers` stays out deliberately: a refresh whose servers
840
+ // moved is already rejected unless the operator pinned an explicit baseUrl,
841
+ // and that override wins over the spec anyway (see agent/openapi.ts).
842
+ fingerprint: canonicalJson({
843
+ operation,
844
+ parameters,
845
+ security: operation["security"] === void 0 ? doc.security ?? null : null,
846
+ definitions: reachableDefinitions(doc, [operation, parameters])
847
+ })
848
+ });
849
+ }
729
850
  }
730
851
  return operations;
731
852
  }
853
+ function matchPath(glob, path) {
854
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
855
+ const trailingDeep = escaped.endsWith("/**");
856
+ const body = trailingDeep ? escaped.slice(0, -3) : escaped;
857
+ const pattern = body.split("**").map((chunk) => chunk.replace(/\*/g, "[^/]*")).join(".*") + (trailingDeep ? "(?:/.*)?" : "");
858
+ return new RegExp(`^${pattern}$`).test(path);
859
+ }
732
860
  function compareApiDocuments(before, after) {
733
861
  const old = apiOperations(before), next = apiOperations(after);
734
862
  return {
@@ -759,6 +887,7 @@ function compareApiDocuments(before, after) {
759
887
  featuresConfigSchema,
760
888
  frankieConfigSchema,
761
889
  limitsConfigSchema,
890
+ matchPath,
762
891
  mcpServerSchema,
763
892
  modelChoiceSchema,
764
893
  modelConfigSchema,