@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.cjs +224 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +45 -2
- package/dist/index.d.ts +45 -2
- package/dist/index.js +217 -27
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
package/dist/index.cjs
CHANGED
|
@@ -23,11 +23,15 @@ __export(index_exports, {
|
|
|
23
23
|
activeSkills: () => activeSkills,
|
|
24
24
|
agentConfigSchema: () => agentConfigSchema,
|
|
25
25
|
apiCapabilitySchema: () => apiCapabilitySchema,
|
|
26
|
+
apiMethods: () => apiMethods,
|
|
27
|
+
apiOperations: () => apiOperations,
|
|
26
28
|
apiSourceSchema: () => apiSourceSchema,
|
|
27
29
|
approvedSkillTools: () => approvedSkillTools,
|
|
28
30
|
assertNoCommittedFrankieKeys: () => assertNoCommittedFrankieKeys,
|
|
29
31
|
authConfigSchema: () => authConfigSchema,
|
|
30
32
|
brandingConfigSchema: () => brandingConfigSchema,
|
|
33
|
+
canonicalJson: () => canonicalJson,
|
|
34
|
+
compareApiDocuments: () => compareApiDocuments,
|
|
31
35
|
defineFrankieConfig: () => defineFrankieConfig,
|
|
32
36
|
describeTool: () => describeTool,
|
|
33
37
|
draftSkill: () => draftSkill,
|
|
@@ -35,15 +39,18 @@ __export(index_exports, {
|
|
|
35
39
|
featuresConfigSchema: () => featuresConfigSchema,
|
|
36
40
|
frankieConfigSchema: () => frankieConfigSchema,
|
|
37
41
|
limitsConfigSchema: () => limitsConfigSchema,
|
|
42
|
+
matchPath: () => matchPath,
|
|
38
43
|
mcpServerSchema: () => mcpServerSchema,
|
|
39
44
|
modelChoiceSchema: () => modelChoiceSchema,
|
|
40
45
|
modelConfigSchema: () => modelConfigSchema,
|
|
46
|
+
operationId: () => operationId,
|
|
41
47
|
parseFrankieConfig: () => parseFrankieConfig,
|
|
42
48
|
planHostedInstall: () => planHostedInstall,
|
|
43
49
|
sdkUpdateWorkflow: () => sdkUpdateWorkflow,
|
|
44
50
|
skillSchema: () => skillSchema,
|
|
45
51
|
thinkingLevelSchema: () => thinkingLevelSchema,
|
|
46
52
|
toolPolicySchema: () => toolPolicySchema,
|
|
53
|
+
validateApiDocument: () => validateApiDocument,
|
|
47
54
|
widgetThemeSchema: () => widgetThemeSchema
|
|
48
55
|
});
|
|
49
56
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -106,7 +113,7 @@ var skillSchema = import_zod.z.object({
|
|
|
106
113
|
function activeSkills(config, availableTools) {
|
|
107
114
|
const approved = new Set(approvedSkillTools(config));
|
|
108
115
|
const available = availableTools ? new Set(availableTools) : approved;
|
|
109
|
-
return config.skills.filter((skill) => skill.status === "published"
|
|
116
|
+
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);
|
|
110
117
|
}
|
|
111
118
|
function approvedSkillTools(config) {
|
|
112
119
|
const candidates = [
|
|
@@ -147,10 +154,16 @@ var modelConfigSchema = import_zod2.z.object({
|
|
|
147
154
|
*/
|
|
148
155
|
provider: import_zod2.z.string().min(1).default("fireworks"),
|
|
149
156
|
/**
|
|
150
|
-
* Model id within that provider. The default is Kimi
|
|
151
|
-
*
|
|
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.
|
|
152
165
|
*/
|
|
153
|
-
model: import_zod2.z.string().default("accounts/fireworks/models/kimi-
|
|
166
|
+
model: import_zod2.z.string().default("accounts/fireworks/models/kimi-k3"),
|
|
154
167
|
/** Env var holding the provider key. Never put the key in config itself. */
|
|
155
168
|
apiKeyEnv: import_zod2.z.string().default("FIREWORKS_API_KEY"),
|
|
156
169
|
/** Override the provider's base URL (self-hosted gateways, tests). */
|
|
@@ -224,7 +237,16 @@ var apiCapabilitySchema = import_zod2.z.object({
|
|
|
224
237
|
operationId: import_zod2.z.string().min(1),
|
|
225
238
|
method: import_zod2.z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
|
226
239
|
path: import_zod2.z.string().min(1),
|
|
227
|
-
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)
|
|
228
250
|
});
|
|
229
251
|
var apiSourceSchema = import_zod2.z.object({
|
|
230
252
|
name: import_zod2.z.string().min(1).regex(/^[a-z0-9][a-z0-9_-]*$/i, "API source names must be alphanumeric with - or _"),
|
|
@@ -263,6 +285,17 @@ var apiSourceSchema = import_zod2.z.object({
|
|
|
263
285
|
forwardUserTokenAs: import_zod2.z.string().optional(),
|
|
264
286
|
/** Extra claims from the user token to forward as `x-frankie-<claim>`. */
|
|
265
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({}),
|
|
266
299
|
/** Per-call timeout. */
|
|
267
300
|
timeoutMs: import_zod2.z.number().int().positive().default(3e4),
|
|
268
301
|
/** Response bodies longer than this are truncated before the model sees them. */
|
|
@@ -348,8 +381,16 @@ var frankieConfigSchema = import_zod2.z.object({
|
|
|
348
381
|
/** Opt-in end-user chat capabilities (attachments, model picker). */
|
|
349
382
|
features: featuresConfigSchema
|
|
350
383
|
});
|
|
384
|
+
var LEAKY_MODEL_MIGRATION = {
|
|
385
|
+
"accounts/fireworks/models/kimi-k2p7-code": "accounts/fireworks/models/kimi-k3"
|
|
386
|
+
};
|
|
351
387
|
function parseFrankieConfig(input) {
|
|
352
|
-
|
|
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;
|
|
353
394
|
}
|
|
354
395
|
function defineFrankieConfig(config) {
|
|
355
396
|
return config;
|
|
@@ -387,7 +428,7 @@ function planHostedInstall(input) {
|
|
|
387
428
|
const tokenPath = appRoot + "api/frankie/token/route.ts";
|
|
388
429
|
const needsAuthSetup = !workspace.allowAnonymous && !files[tokenPath] && !firebase && !authJs;
|
|
389
430
|
if (needsAuthSetup) notes.push("Connect getCurrentUser in the generated token endpoint to your server session. It rejects requests until configured.");
|
|
390
|
-
pkg.dependencies = { ...pkg.dependencies, "@frankieai/react": pkg.dependencies["@frankieai/react"] ?? "^0.2.
|
|
431
|
+
pkg.dependencies = { ...pkg.dependencies, "@frankieai/react": pkg.dependencies["@frankieai/react"] ?? "^0.2.1", ...!workspace.allowAnonymous ? { jose: pkg.dependencies.jose ?? "^6.0.11" } : {} };
|
|
391
432
|
out.set(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
392
433
|
const authProp = workspace.allowAnonymous ? "" : " getUserToken={getUserToken}";
|
|
393
434
|
const shared = `import { FrankieProvider, FrankieWidget, FrankieChatPage } from "@frankieai/react";
|
|
@@ -494,29 +535,34 @@ export async function POST() {
|
|
|
494
535
|
} catch {
|
|
495
536
|
}
|
|
496
537
|
}
|
|
538
|
+
const middlewareMatchers = nextMiddlewareMatchers(files, sourceRoot, root);
|
|
497
539
|
const paths = {};
|
|
498
|
-
|
|
540
|
+
let withheld = 0;
|
|
541
|
+
for (const [file, content] of Object.entries(files)) {
|
|
499
542
|
if (/\.(test|spec)\.[tj]sx?$/.test(file)) continue;
|
|
500
543
|
const nextRoute = file.match(/(?:^|\/)app(\/.*)\/route\.[tj]s$/);
|
|
501
544
|
if (nextRoute) {
|
|
502
545
|
const path = nextRoute[1].replace(/\/\([^/]+\)/g, "").replace(/\[([^\]]+)\]/g, "{$1}");
|
|
503
546
|
if (path.includes("...") || /\/(?:auth|frankie)(?:\/|$)/.test(path)) continue;
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
547
|
+
const guarded = matchesAnyMatcher(middlewareMatchers, path);
|
|
548
|
+
for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
|
|
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
|
+
}
|
|
507
552
|
}
|
|
508
553
|
} else if (/\.[tj]s$/.test(file) && /(?:routes?|api)\//.test(file)) {
|
|
509
|
-
for (const match of content.matchAll(/\b(?:app|router|fastify)\.(get|post|put|patch|delete)(?:\s*<[^;]{1,1600}?>)?\s*\(\s*["']([^"']+)["']
|
|
510
|
-
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;
|
|
511
556
|
}
|
|
512
557
|
}
|
|
513
|
-
doc ??= { openapi: "3.0.3", info: { title: workspace.name + " API", version: "1.0.0", description: "
|
|
514
|
-
|
|
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 };
|
|
559
|
+
for (const [path, item] of Object.entries(paths)) doc.paths[path] = { ...item, ...doc.paths[path] };
|
|
515
560
|
const specPath = root + "public/frankie.openapi.json";
|
|
516
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);
|
|
517
562
|
out.set(specPath, JSON.stringify(doc, null, 2) + "\n");
|
|
518
563
|
if (!operationCount) notes.push("No supported API routes detected. The OpenAPI draft has no operations; add the real schema before connecting tools.");
|
|
519
|
-
notes.push(
|
|
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.`);
|
|
565
|
+
notes.push("Generated OpenAPI includes read and write routes. Review schemas before approving tools; new connections still default to GET-only access.");
|
|
520
566
|
notes.push("Verify the public spec URL is reachable without dashboard authentication before connecting it.");
|
|
521
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.");
|
|
522
568
|
const envPath = root + ".env.example";
|
|
@@ -530,7 +576,7 @@ Workspace: ${workspace.name}
|
|
|
530
576
|
${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.
|
|
531
577
|
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.
|
|
532
578
|
5. Open ${chatPath}. It is created inside the detected authenticated dashboard group when one exists; add its link to the host sidebar during review.
|
|
533
|
-
6. Connect https://YOUR_APP/frankie.openapi.json in Frankie \u2192 Tools & MCP. The
|
|
579
|
+
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.
|
|
534
580
|
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.
|
|
535
581
|
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.
|
|
536
582
|
|
|
@@ -542,8 +588,10 @@ ${notes.map((n) => "- " + n).join("\n")}
|
|
|
542
588
|
assertNoCommittedFrankieKeys(planned);
|
|
543
589
|
return { files: planned, root, chatPath, specPath, operationCount, needsAuthSetup, notes };
|
|
544
590
|
}
|
|
545
|
-
|
|
546
|
-
|
|
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;
|
|
547
595
|
(paths[path] ??= {})[method.toLowerCase()] = {
|
|
548
596
|
operationId: (method.toLowerCase() + "_" + path).replace(/[^a-zA-Z0-9_]+/g, "_"),
|
|
549
597
|
summary: method + " " + path,
|
|
@@ -552,6 +600,32 @@ function addInstallOperation(paths, method, path, file) {
|
|
|
552
600
|
responses: { "200": { description: "Successful response" } },
|
|
553
601
|
"x-frankie-review-required": true
|
|
554
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
|
+
});
|
|
555
629
|
}
|
|
556
630
|
|
|
557
631
|
// src/sdk-updates.ts
|
|
@@ -578,20 +652,20 @@ jobs:
|
|
|
578
652
|
outputs:
|
|
579
653
|
base: $GH steps.base.outputs.sha }}
|
|
580
654
|
steps:
|
|
581
|
-
- uses: actions/checkout@
|
|
655
|
+
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
|
|
582
656
|
with:
|
|
583
657
|
persist-credentials: false
|
|
584
658
|
- id: base
|
|
585
659
|
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
|
586
|
-
- uses: actions/setup-node@
|
|
660
|
+
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444
|
|
587
661
|
with:
|
|
588
|
-
node-version:
|
|
662
|
+
node-version: 24
|
|
589
663
|
- name: Install pinned pnpm
|
|
590
664
|
run: npm install --global --ignore-scripts pnpm@10.33.0
|
|
591
665
|
- name: Prepare SDK and lockfile together
|
|
592
666
|
run: |
|
|
593
667
|
test -f pnpm-lock.yaml
|
|
594
|
-
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
|
|
595
669
|
- name: Package allowlisted changes
|
|
596
670
|
run: |
|
|
597
671
|
node --input-type=module <<'NODE'
|
|
@@ -607,7 +681,7 @@ jobs:
|
|
|
607
681
|
mkdirSync('frankie-update-artifact', {recursive:true});
|
|
608
682
|
writeFileSync('frankie-update-artifact/changes.json', JSON.stringify(files));
|
|
609
683
|
NODE
|
|
610
|
-
- uses: actions/upload-artifact@
|
|
684
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
|
611
685
|
with:
|
|
612
686
|
name: frankie-sdk-update
|
|
613
687
|
path: frankie-update-artifact/changes.json
|
|
@@ -621,11 +695,11 @@ jobs:
|
|
|
621
695
|
pull-requests: write
|
|
622
696
|
steps:
|
|
623
697
|
# Separate runner: no checkout, repository hooks, install scripts, or builds.
|
|
624
|
-
- uses: actions/download-artifact@
|
|
698
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
|
625
699
|
with:
|
|
626
700
|
name: frankie-sdk-update
|
|
627
701
|
path: artifact
|
|
628
|
-
- uses: actions/github-script@
|
|
702
|
+
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
|
|
629
703
|
env:
|
|
630
704
|
BASE_SHA: $GH needs.prepare.outputs.base }}
|
|
631
705
|
with:
|
|
@@ -642,6 +716,7 @@ jobs:
|
|
|
642
716
|
if (current.data.object.sha !== base) throw Error('Default branch changed. Run the updater again.');
|
|
643
717
|
const existing = await github.paginate(github.rest.pulls.list, {owner,repo,state:'open',per_page:100});
|
|
644
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);
|
|
645
720
|
const clean = value => {
|
|
646
721
|
const copy = JSON.parse(JSON.stringify(value));
|
|
647
722
|
for (const group of ['dependencies','devDependencies','optionalDependencies']) {
|
|
@@ -649,6 +724,7 @@ jobs:
|
|
|
649
724
|
}
|
|
650
725
|
return copy;
|
|
651
726
|
};
|
|
727
|
+
let sdkChanged = false;
|
|
652
728
|
const seen = new Set();
|
|
653
729
|
for (const file of files) {
|
|
654
730
|
if (!file || typeof file.path !== 'string' || typeof file.content !== 'string' || seen.has(file.path)) throw Error('Invalid update file');
|
|
@@ -659,14 +735,16 @@ jobs:
|
|
|
659
735
|
const original = await github.rest.repos.getContent({owner,repo,path:file.path,ref:base});
|
|
660
736
|
const before = JSON.parse(Buffer.from(original.data.content, 'base64').toString());
|
|
661
737
|
const after = JSON.parse(file.content);
|
|
662
|
-
if (
|
|
738
|
+
if (canonical(clean(before)) !== canonical(clean(after))) throw Error('Non-SDK manifest change in ' + file.path);
|
|
663
739
|
for (const group of ['dependencies','devDependencies','optionalDependencies']) {
|
|
664
740
|
const sdkKeys = value => Object.keys(value[group] || {}).filter(key => key.startsWith('@frankieai/')).sort();
|
|
665
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;
|
|
666
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');
|
|
667
744
|
}
|
|
668
745
|
}
|
|
669
746
|
}
|
|
747
|
+
if (!sdkChanged) { core.notice('Frankie SDK versions are unchanged; no formatting-only update PR is needed.'); return; }
|
|
670
748
|
if (!seen.has('pnpm-lock.yaml')) throw Error('SDK update must include the lockfile');
|
|
671
749
|
const parent = await github.rest.git.getCommit({owner,repo,commit_sha:base});
|
|
672
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'}))});
|
|
@@ -676,16 +754,132 @@ jobs:
|
|
|
676
754
|
await github.rest.git.createRef({owner,repo,ref:'refs/heads/' + branch,sha:commit.data.sha});
|
|
677
755
|
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.'});
|
|
678
756
|
`;
|
|
757
|
+
|
|
758
|
+
// src/openapi-review.ts
|
|
759
|
+
var apiMethods = ["get", "post", "put", "patch", "delete", "head", "options", "trace"];
|
|
760
|
+
function canonicalJson(value) {
|
|
761
|
+
if (Array.isArray(value)) return "[" + value.map(canonicalJson).join(",") + "]";
|
|
762
|
+
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(",") + "}";
|
|
763
|
+
return JSON.stringify(value) ?? "null";
|
|
764
|
+
}
|
|
765
|
+
function validateApiDocument(value) {
|
|
766
|
+
const doc = value;
|
|
767
|
+
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.");
|
|
768
|
+
const ids = /* @__PURE__ */ new Set();
|
|
769
|
+
const walk = (node, depth = 0) => {
|
|
770
|
+
if (depth > 80) throw new Error("Spec nesting is too deep.");
|
|
771
|
+
if (!node || typeof node !== "object") return;
|
|
772
|
+
for (const [key, item] of Object.entries(node)) {
|
|
773
|
+
if (["__proto__", "prototype", "constructor"].includes(key)) throw new Error("Spec contains an unsafe property.");
|
|
774
|
+
if (key === "$ref" && (typeof item !== "string" || !item.startsWith("#/"))) throw new Error("Bundle external references into this spec before refreshing.");
|
|
775
|
+
walk(item, depth + 1);
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
walk(doc);
|
|
779
|
+
for (const [path, item] of Object.entries(doc.paths)) {
|
|
780
|
+
if (!path.startsWith("/") || !item || typeof item !== "object" || Array.isArray(item) || item.$ref) throw new Error("Use inline path items with absolute API paths.");
|
|
781
|
+
for (const method of apiMethods) {
|
|
782
|
+
const op = item[method];
|
|
783
|
+
if (!op) continue;
|
|
784
|
+
if (typeof op !== "object" || Array.isArray(op)) throw new Error("Invalid API operation.");
|
|
785
|
+
const id = operationId(method, path, op);
|
|
786
|
+
const toolId = id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 48);
|
|
787
|
+
if (ids.has(toolId)) throw new Error("Operation IDs must be unique, including their tool-safe names.");
|
|
788
|
+
ids.add(toolId);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
function operationId(method, path, op) {
|
|
793
|
+
return typeof op.operationId === "string" ? op.operationId : `${method}_${path.replace(/[{}]/g, "").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_|_$/g, "")}`;
|
|
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
|
+
}
|
|
825
|
+
function apiOperations(doc) {
|
|
826
|
+
const operations = /* @__PURE__ */ new Map();
|
|
827
|
+
for (const [path, item] of Object.entries(doc.paths)) {
|
|
828
|
+
const parameters = Object.fromEntries(Object.entries(item).filter(([key]) => !apiMethods.includes(key)));
|
|
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
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return operations;
|
|
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
|
+
}
|
|
860
|
+
function compareApiDocuments(before, after) {
|
|
861
|
+
const old = apiOperations(before), next = apiOperations(after);
|
|
862
|
+
return {
|
|
863
|
+
added: [...next.keys()].filter((key) => !old.has(key)),
|
|
864
|
+
changed: [...next.keys()].filter((key) => old.has(key) && old.get(key).fingerprint !== next.get(key).fingerprint),
|
|
865
|
+
removed: [...old.keys()].filter((key) => !next.has(key)),
|
|
866
|
+
unchangedIds: [...next].filter(([key, value]) => old.get(key)?.fingerprint === value.fingerprint).map(([, value]) => value.id)
|
|
867
|
+
};
|
|
868
|
+
}
|
|
679
869
|
// Annotate the CommonJS export names for ESM import in node:
|
|
680
870
|
0 && (module.exports = {
|
|
681
871
|
activeSkills,
|
|
682
872
|
agentConfigSchema,
|
|
683
873
|
apiCapabilitySchema,
|
|
874
|
+
apiMethods,
|
|
875
|
+
apiOperations,
|
|
684
876
|
apiSourceSchema,
|
|
685
877
|
approvedSkillTools,
|
|
686
878
|
assertNoCommittedFrankieKeys,
|
|
687
879
|
authConfigSchema,
|
|
688
880
|
brandingConfigSchema,
|
|
881
|
+
canonicalJson,
|
|
882
|
+
compareApiDocuments,
|
|
689
883
|
defineFrankieConfig,
|
|
690
884
|
describeTool,
|
|
691
885
|
draftSkill,
|
|
@@ -693,15 +887,18 @@ jobs:
|
|
|
693
887
|
featuresConfigSchema,
|
|
694
888
|
frankieConfigSchema,
|
|
695
889
|
limitsConfigSchema,
|
|
890
|
+
matchPath,
|
|
696
891
|
mcpServerSchema,
|
|
697
892
|
modelChoiceSchema,
|
|
698
893
|
modelConfigSchema,
|
|
894
|
+
operationId,
|
|
699
895
|
parseFrankieConfig,
|
|
700
896
|
planHostedInstall,
|
|
701
897
|
sdkUpdateWorkflow,
|
|
702
898
|
skillSchema,
|
|
703
899
|
thinkingLevelSchema,
|
|
704
900
|
toolPolicySchema,
|
|
901
|
+
validateApiDocument,
|
|
705
902
|
widgetThemeSchema
|
|
706
903
|
});
|
|
707
904
|
//# sourceMappingURL=index.cjs.map
|