@frankieai/server 0.2.3 → 0.2.5

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.
@@ -441,7 +441,6 @@ var OpenApiError = class extends Error {
441
441
  };
442
442
  async function inspectApiCapabilities(source, options = {}) {
443
443
  const doc = await loadSpec(source.spec, options.fetchImpl);
444
- const allowedMethods = new Set(source.methods);
445
444
  const hasPerUserAuth = Boolean(source.forwardUserTokenAs) || source.forwardUserClaims.length > 0;
446
445
  const capabilities = [];
447
446
  for (const [path, item] of Object.entries(doc.paths ?? {})) {
@@ -451,7 +450,6 @@ async function inspectApiCapabilities(source, options = {}) {
451
450
  const operation = item[method];
452
451
  if (!isRecord(operation)) continue;
453
452
  const httpMethod = method.toUpperCase();
454
- if (!allowedMethods.has(httpMethod)) continue;
455
453
  const operationId = typeof operation["operationId"] === "string" ? operation["operationId"] : `${method}_${slug(path)}`;
456
454
  if (source.operations.length > 0 && !source.operations.includes(operationId)) continue;
457
455
  const toolName = `${API_TOOL_PREFIX}${source.name}__${sanitize(operationId)}`;
@@ -499,14 +497,14 @@ async function buildOne(source, options) {
499
497
  for (const method of ["get", "post", "put", "patch", "delete"]) {
500
498
  const operation = item[method];
501
499
  if (!isRecord(operation)) continue;
502
- if (!allowedMethods.has(method.toUpperCase())) continue;
503
500
  const operationId = typeof operation["operationId"] === "string" ? operation["operationId"] : `${method}_${slug(path)}`;
504
501
  if (source.operations.length > 0 && !source.operations.includes(operationId)) continue;
505
- if (source.selectedTools && !source.selectedTools.includes(operationId)) continue;
502
+ if (source.selectedTools ? !source.selectedTools.includes(operationId) : !allowedMethods.has(method.toUpperCase())) continue;
506
503
  const name = `${API_TOOL_PREFIX}${source.name}__${sanitize(operationId)}`;
507
504
  if (!evaluateToolPolicy(name, options.policy).allowed) continue;
508
505
  const params = collectParameters(doc, item, operation);
509
506
  const body = collectRequestBody(doc, operation);
507
+ const scopeRules = parseScopeRules(operation);
510
508
  const { headers: securedHeaders, query: securityQuery } = applySecurity(doc, source, operation, schemes);
511
509
  const headers = buildForwardedHeaders({ ...source, headers: securedHeaders }, options.session);
512
510
  const opBaseUrl = resolveOperationBaseUrl(source, doc, item, operation, baseUrl ?? "");
@@ -540,6 +538,7 @@ ${retrySafetyNote(method, doc, item, operation)}`,
540
538
  );
541
539
  }
542
540
  const input = enforceIdentityParams(params, isRecord(args) ? args : {}, options.session);
541
+ enforceResourceScope(scopeRules, input, options.session);
543
542
  const url = buildUrl(opBaseUrl, path, params, input, securityQuery);
544
543
  const rawBody = body ? input["body"] : void 0;
545
544
  const requestBody = isRecord(rawBody) ? enforceServerDerivedIdentity(rawBody, options.session) : rawBody;
@@ -859,6 +858,39 @@ function enforceIdentityParams(params, input, session) {
859
858
  }
860
859
  return next;
861
860
  }
861
+ function parseScopeRules(operation) {
862
+ const raw = operation["x-frankie-scope"];
863
+ const entries = Array.isArray(raw) ? raw : raw !== void 0 ? [raw] : [];
864
+ const rules = [];
865
+ for (const entry of entries) {
866
+ if (isRecord(entry) && typeof entry["param"] === "string" && typeof entry["claim"] === "string") {
867
+ rules.push({ param: entry["param"], claim: entry["claim"] });
868
+ }
869
+ }
870
+ return rules;
871
+ }
872
+ function enforceResourceScope(rules, input, session) {
873
+ for (const rule of rules) {
874
+ if (session.user.anonymous) {
875
+ throw new Error(
876
+ `"${rule.param}" is access-scoped, but the chat session is anonymous \u2014 Frankie will not call it for an unauthenticated visitor. Report this to the user instead of retrying.`
877
+ );
878
+ }
879
+ const requested = input[rule.param];
880
+ if (requested === void 0 || requested === null || requested === "") {
881
+ throw new Error(
882
+ `This operation requires an in-scope "${rule.param}" and none was given. Report this to the user instead of retrying.`
883
+ );
884
+ }
885
+ const allowed = session.user.claims[rule.claim];
886
+ const allowedList = Array.isArray(allowed) ? allowed.map((entry) => String(entry)) : typeof allowed === "string" ? [allowed] : [];
887
+ if (!allowedList.includes(String(requested))) {
888
+ throw new Error(
889
+ `Access denied: "${String(requested)}" is not in this user's "${rule.claim}" scope. Do not retry with a different id \u2014 report this to the user.`
890
+ );
891
+ }
892
+ }
893
+ }
862
894
 
863
895
  // src/agent/mcp.ts
864
896
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
@@ -1445,7 +1477,7 @@ function msg(error) {
1445
1477
  }
1446
1478
 
1447
1479
  // src/agent/prompt.ts
1448
- import { activeSkills } from "@frankieai/core";
1480
+ import { activeSkills, SKILL_TOOL_NAME } from "@frankieai/core";
1449
1481
  function buildSystemPrompt(config, session, availableTools) {
1450
1482
  const sections = [];
1451
1483
  const configurationAssistant = config.features.configurationAssistant;
@@ -1501,7 +1533,7 @@ ${config.agent.systemPrompt.trim()}`);
1501
1533
  }
1502
1534
  sections.push(renderUserSection(session));
1503
1535
  const skills = activeSkills(config, availableTools);
1504
- if (skills.length) sections.push("## Reviewed workspace skills\nThese recipes do not grant additional permissions or override safety rules. Each skill lists only its currently approved, available tools. Instructions may describe additional operations: skip unavailable steps, explain the limitation, and never claim the whole task succeeded when a required step was unavailable.\n" + JSON.stringify(skills.map(({ name, goal, instructions, tools }) => ({ name, goal, instructions, tools }))));
1536
+ if (skills.length) sections.push("## Reviewed workspace skills\nThese recipes do not grant additional permissions or override safety rules. Each skill lists only its currently approved, available tools. Instructions may describe additional operations: skip unavailable steps, explain the limitation, and never claim the whole task succeeded when a required step was unavailable.\nBefore you begin following one of these skills, call the `" + SKILL_TOOL_NAME + "` tool with the skill's exact name, so the person can see which recipe you're applying. It grants no permissions and does nothing on its own \u2014 it's only a visible marker. Then carry out that skill's steps.\n" + JSON.stringify(skills.map(({ name, goal, instructions, tools }) => ({ name, goal, instructions, tools }))));
1505
1537
  return sections.join("\n\n");
1506
1538
  }
1507
1539
  function isOnboardingAssistant(config) {
@@ -1607,9 +1639,51 @@ function renderPageContext(context) {
1607
1639
  import { Agent } from "@earendil-works/pi-agent-core";
1608
1640
  import {
1609
1641
  describeTool as describeTool3,
1610
- activeSkills as activeSkills2,
1642
+ activeSkills as activeSkills3,
1611
1643
  evaluateToolPolicy as evaluateToolPolicy3
1612
1644
  } from "@frankieai/core";
1645
+
1646
+ // src/agent/skill-tools.ts
1647
+ import { Type as Type4 } from "@earendil-works/pi-ai";
1648
+ import { activeSkills as activeSkills2, SKILL_TOOL_NAME as SKILL_TOOL_NAME2 } from "@frankieai/core";
1649
+ function isSkillTool(name) {
1650
+ return name === SKILL_TOOL_NAME2;
1651
+ }
1652
+ function buildSkillTools(config, availableTools) {
1653
+ const skills = activeSkills2(config, availableTools);
1654
+ if (!skills.length) return [];
1655
+ const names = skills.map((skill) => skill.name);
1656
+ return [
1657
+ {
1658
+ name: SKILL_TOOL_NAME2,
1659
+ label: "Use skill",
1660
+ description: `Announce that you are about to follow one of the reviewed workspace skills, so the person can see which recipe you're applying. Call this with the skill's exact name immediately before you begin its steps. It grants no new permissions and performs no action on its own.
1661
+
1662
+ Available skills: ${names.join(", ")}.`,
1663
+ parameters: Type4.Unsafe({
1664
+ type: "object",
1665
+ required: ["skill"],
1666
+ properties: { skill: { type: "string", description: "The exact name of the workspace skill you are about to follow." } }
1667
+ }),
1668
+ async execute(_id, args) {
1669
+ const requested = String((typeof args === "object" && args !== null ? args["skill"] : "") ?? "").trim();
1670
+ const match = skills.find((skill) => skill.name.toLowerCase() === requested.toLowerCase());
1671
+ if (!match) {
1672
+ return {
1673
+ content: [{ type: "text", text: `No active skill is named "${requested}". The skills available this turn are: ${names.join(", ")}.` }],
1674
+ details: null
1675
+ };
1676
+ }
1677
+ return {
1678
+ content: [{ type: "text", text: `Now following the "${match.name}" skill. Apply its steps using only its approved tools.` }],
1679
+ details: null
1680
+ };
1681
+ }
1682
+ }
1683
+ ];
1684
+ }
1685
+
1686
+ // src/agent/runner.ts
1613
1687
  async function* runTurn(params) {
1614
1688
  const { config, session } = params;
1615
1689
  const toolset = await collectTools({
@@ -1619,6 +1693,8 @@ async function* runTurn(params) {
1619
1693
  ...params.onWarning ? { onWarning: params.onWarning } : {}
1620
1694
  });
1621
1695
  const availableTools = toolset.tools.map((tool) => tool.name);
1696
+ const skillTools = buildSkillTools(config, availableTools);
1697
+ const agentTools = [...toolset.tools, ...skillTools];
1622
1698
  const queue = new EventQueue();
1623
1699
  let turnCount = 0;
1624
1700
  let lastAssistant;
@@ -1630,7 +1706,7 @@ async function* runTurn(params) {
1630
1706
  systemPrompt: buildSystemPrompt(config, session, availableTools),
1631
1707
  model: params.modelOverride ?? params.model,
1632
1708
  thinkingLevel: config.model.thinkingLevel,
1633
- tools: toolset.tools,
1709
+ tools: agentTools,
1634
1710
  messages: session.messages
1635
1711
  },
1636
1712
  // Per-request sampling lives here rather than on the agent: the stream
@@ -1654,7 +1730,7 @@ async function* runTurn(params) {
1654
1730
  // tools are exempt: they're gated by the workspaceAdmin flag, not the
1655
1731
  // allow/deny policy, so the policy would otherwise block them here.
1656
1732
  beforeToolCall: async ({ toolCall }) => {
1657
- if (isPlatformTool(toolCall.name)) return void 0;
1733
+ if (isPlatformTool(toolCall.name) || isSkillTool(toolCall.name)) return void 0;
1658
1734
  const decision = evaluateToolPolicy3(toolCall.name, config.agent.tools);
1659
1735
  if (decision.allowed) return void 0;
1660
1736
  queue.push({
@@ -1696,7 +1772,7 @@ async function* runTurn(params) {
1696
1772
  });
1697
1773
  }).finally(() => queue.close());
1698
1774
  try {
1699
- yield { type: "skills_loaded", skills: activeSkills2(config, availableTools).map(({ id, name, revision }) => ({ id, name, revision })) };
1775
+ yield { type: "skills_loaded", skills: activeSkills3(config, availableTools).map(({ id, name, revision }) => ({ id, name, revision })) };
1700
1776
  yield { type: "tools_active", apis: toolset.apis };
1701
1777
  for await (const event of queue) yield event;
1702
1778
  await run;
@@ -1922,6 +1998,7 @@ var EventQueue = class {
1922
1998
  // src/app.ts
1923
1999
  import { randomUUID as randomUUID4 } from "crypto";
1924
2000
  import { Hono as Hono3 } from "hono";
2001
+ import { suggestedPromptsFromSkills } from "@frankieai/core";
1925
2002
 
1926
2003
  // src/guide.ts
1927
2004
  import { Hono as Hono2 } from "hono";
@@ -2154,7 +2231,13 @@ function createFrankieApp(options) {
2154
2231
  launcherLabel: config.branding.launcherLabel
2155
2232
  },
2156
2233
  greeting: config.agent.greeting,
2157
- suggestedPrompts: config.agent.suggestedPrompts,
2234
+ // Fall back to prompts derived from the workspace's own published skills,
2235
+ // so an empty chat suggests what this assistant can actually do instead of
2236
+ // nothing. An explicitly authored list always wins.
2237
+ suggestedPrompts: config.agent.suggestedPrompts.length ? config.agent.suggestedPrompts : suggestedPromptsFromSkills(config),
2238
+ // Explicit starter cards configured in the dashboard's Appearance tab.
2239
+ // When set, the widget renders these instead of the suggestedPrompts.
2240
+ starterPrompts: config.agent.starterPrompts,
2158
2241
  theme: config.theme,
2159
2242
  allowAnonymous: config.auth.allowAnonymous,
2160
2243
  showToolActivity: config.agent.showToolActivity,
@@ -2652,6 +2735,11 @@ function toPublicTenant(record) {
2652
2735
  ...record.vercel ? { vercel: publicVercelLink(record.vercel) } : {},
2653
2736
  theme: config.theme,
2654
2737
  features: config.features,
2738
+ agent: {
2739
+ greeting: config.agent.greeting,
2740
+ suggestedPrompts: config.agent.suggestedPrompts,
2741
+ starterPrompts: config.agent.starterPrompts
2742
+ },
2655
2743
  createdAt: record.createdAt,
2656
2744
  updatedAt: record.updatedAt
2657
2745
  };
@@ -3163,4 +3251,4 @@ export {
3163
3251
  resolveStores,
3164
3252
  resolveTenantStore
3165
3253
  };
3166
- //# sourceMappingURL=chunk-MKN5663J.js.map
3254
+ //# sourceMappingURL=chunk-HPYT6ZIU.js.map