Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/gateway-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ OAuth link.
Composio v3.1 REST pages and catalog/tool payloads are byte-bounded before
parsing, then schema- and cardinality-bounded so a provider pagination fault
cannot grow Worker memory without limit.
The lightweight `/v1/composer/skills` catalog reads active connected-app slugs from the reconciled
database state alongside custom skills; opening `@` never waits on a provider catalog or account sync.
Catalog and connected-account provider snapshots may load in parallel, but DB
reconciliation begins only after both external reads settle. Connect creates
the provider link first and compensates by deleting it if response validation
Expand Down
7 changes: 6 additions & 1 deletion apps/gateway-worker/src/core-http-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { authenticate } from "./authenticate";
import { type GatewayApp, type GatewayContext, requestDatabase } from "./gateway-env";
import { rateLimit, rateLimitPublic, withRateLimitHeaders } from "./rate-limit";
import { readDownstreamReleaseHealth } from "./release-health";
import { listUserSkillsRoute } from "./skills-routes";
import { listComposerSkillsRoute, listUserSkillsRoute } from "./skills-routes";
import { clientErrorRoute, clientUserEventRoute, vitalsRoute } from "./telemetry-routes";

export function registerCoreHttpRoutes(app: GatewayApp): void {
Expand Down Expand Up @@ -82,6 +82,11 @@ function registerOutputRoute(app: GatewayApp): void {
}

function registerSkillRoutes(app: GatewayApp): void {
app.get("/v1/composer/skills", async (c) => {
const userId = await authenticate(c);
await rateLimit(c, userId);
return listComposerSkillsRoute(requestDatabase(c), userId);
});
app.get("/v1/skills", async (c) => {
const userId = await authenticate(c);
await rateLimit(c, userId);
Expand Down
40 changes: 38 additions & 2 deletions apps/gateway-worker/src/skills-routes.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import {
type DatabaseHandle,
listActiveUserIntegrationNames,
listUserSkillSummaries,
type UserSkillSummary,
withUserDb,
} from "@cheatcode/db";
import type { UserId } from "@cheatcode/types";
import { MAX_USER_SKILLS, UserSkillSchema, UserSkillsResponseSchema } from "@cheatcode/types/api";
import { IntegrationNameSchema, integrationDisplayName, type UserId } from "@cheatcode/types";
import {
ComposerSkillsResponseSchema,
MAX_CONNECTED_APP_SKILLS,
MAX_USER_SKILLS,
UserSkillSchema,
UserSkillsResponseSchema,
} from "@cheatcode/types/api";

function skillSummary(record: UserSkillSummary): unknown {
return UserSkillSchema.parse({
Expand All @@ -29,3 +36,32 @@ export async function listUserSkillsRoute(
return Response.json(UserSkillsResponseSchema.parse({ skills: rows.map(skillSummary) }));
});
}

/** `GET /v1/composer/skills` — custom skills plus active connected-app capabilities. */
export async function listComposerSkillsRoute(
database: DatabaseHandle,
userId: UserId,
): Promise<Response> {
return withUserDb(database, userId, async ({ transaction }) => {
const catalog = await transaction(async (tx) => {
const skillRows = await listUserSkillSummaries(tx, userId, MAX_USER_SKILLS);
const integrationNames = await listActiveUserIntegrationNames(
tx,
userId,
MAX_CONNECTED_APP_SKILLS,
);
return {
connectedApps: integrationNames.flatMap(connectedAppSummary),
skills: skillRows.map(skillSummary),
};
});
return Response.json(ComposerSkillsResponseSchema.parse(catalog));
});
}

function connectedAppSummary(name: string): Array<{ displayName: string; name: string }> {
const parsed = IntegrationNameSchema.safeParse(name);
return parsed.success
? [{ displayName: integrationDisplayName(parsed.data), name: parsed.data }]
: [];
}
3 changes: 2 additions & 1 deletion apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ as raw bounded requests, show per-batch progress and actionable failures, and be
a compact `/uploads/...` reference after each successful save. `/` is exclusively the
persistent project-file browser and merges durable uploads with generated Deliverables. A selected
Deliverable inserts its stable `/deliverables/<output-id>/<filename>` project reference;
`@` is exclusively the user-skill picker. The file browser reads durable project-file metadata and
`@` is exclusively the skill picker: it merges custom skills with active connected apps from the
lightweight database-backed skill catalog. The file browser reads durable project-file metadata and
does not create or wake Daytona merely because the user opens it.
Computer preview wakeups run only while Browser is selected; opening Files never revives an
unrelated dev server or changes the selected surface. Browser wakeups rotate the preview session
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/chat-panel-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import type { OlderMessagesLoadResult } from "@/components/chat/use-message-list
import { agentModelRequestValue } from "@/lib/agent-models";
import { cancelRun, getThread } from "@/lib/api/project-thread";
import { invalidateChatLists, projectKeys, threadKeys } from "@/lib/api/query-keys";
import { USER_SKILLS_QUERY } from "@/lib/api/skills";
import { COMPOSER_SKILLS_QUERY, USER_SKILLS_QUERY } from "@/lib/api/skills";
import { useAppStore } from "@/lib/store/app-store";
import { rememberStreamSeq, streamResumeCursor } from "@/lib/stream/stream-seq";

Expand Down Expand Up @@ -402,6 +402,7 @@ function handleSkillCreatedData(
): void {
const parsed = CHEATCODE_DATA_SCHEMAS["skill-created"].safeParse(data);
if (parsed.success) {
void queryClient.invalidateQueries({ queryKey: COMPOSER_SKILLS_QUERY });
void queryClient.invalidateQueries({ queryKey: USER_SKILLS_QUERY });
actions.setActiveComputerTab("files");
actions.setPreviewPanelOpen(true);
Expand Down
24 changes: 2 additions & 22 deletions apps/web/src/components/composer/composer-context-chips.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,10 @@
"use client";

import type { IntegrationName } from "@cheatcode/types";
import { type IntegrationName, integrationDisplayName } from "@cheatcode/types";
import { Link as LinkIcon, X } from "@/components/ui";
import { CheatcodeMark } from "@/components/ui/cheatcode-mark";
import { cn } from "@/lib/ui/cn";

// Curated display names for the most common toolkits. Any other connected toolkit
// slug falls back to a prettified label via toolLabel().
const TOOL_LABELS: Record<string, string> = {
github: "GitHub",
gmail: "Gmail",
linear: "Linear",
notion: "Notion",
slack: "Slack",
};

function toolLabel(slug: string): string {
return (
TOOL_LABELS[slug] ??
slug
.split("_")
.map((word) => (word ? word.charAt(0).toUpperCase() + word.slice(1) : word))
.join(" ")
);
}

export function ComposerContextChips({
className,
onClearSkill,
Expand All @@ -49,7 +29,7 @@ export function ComposerContextChips({
) : null}
{tool ? (
<ComposerContextChip
label={toolLabel(tool)}
label={integrationDisplayName(tool)}
onClear={onClearTool}
tone="tool"
typeLabel="Tool"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
import type { ToolkitCatalogEntry, UserSkill } from "@cheatcode/types/api";
import type { ConnectedAppSkill, UserSkill } from "@cheatcode/types/api";
import type { ComposerMenuItem } from "@/components/composer/composer-popover";

const MAX_SLASH_ITEMS = 200;
const MAX_MENTION_ITEMS = 200;

/**
* Builds the skill catalog used by the `@` composer trigger. The optional toolkit
* input remains available to non-composer callers, while chat intentionally passes
* skills only so `@` has one predictable meaning.
* Builds the custom-skill and connected-app catalog used by the `@` trigger.
*/
export function slashSkillItems(
export function mentionSkillItems(
query: string,
userSkills: UserSkill[] = [],
toolkits: readonly ToolkitCatalogEntry[] = [],
connectedApps: readonly ConnectedAppSkill[] = [],
): ComposerMenuItem[] {
const needle = query.trim().toLowerCase();
const items: ComposerMenuItem[] = [];
for (const skill of userSkills) {
if (matchesQuery(skill.name, skill.description, needle) && items.length < MAX_SLASH_ITEMS) {
if (matchesQuery(skill.name, skill.description, needle) && items.length < MAX_MENTION_ITEMS) {
items.push({
hint: skill.description,
id: `user-skill:${skill.id}`,
Expand All @@ -27,17 +25,15 @@ export function slashSkillItems(
});
}
}
for (const toolkit of toolkits) {
if (
matchesQuery(toolkit.displayName, toolkit.description, needle) &&
items.length < MAX_SLASH_ITEMS
) {
for (const app of connectedApps) {
const hint = `Use ${app.displayName} through your connected account.`;
if (matchesQuery(app.displayName, hint, needle) && items.length < MAX_MENTION_ITEMS) {
items.push({
hint: toolkit.description,
id: `integration:${toolkit.name}`,
hint,
id: `integration:${app.name}`,
insert: "",
integrationName: toolkit.name,
label: toolkit.displayName,
integrationName: app.name,
label: app.displayName,
visual: "integration",
});
}
Expand Down
16 changes: 8 additions & 8 deletions apps/web/src/components/composer/use-composer-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import type { IntegrationName } from "@cheatcode/types";
import { useQuery } from "@tanstack/react-query";
import type { KeyboardEvent, RefObject } from "react";
import type { ComposerMenuItem } from "@/components/composer/composer-popover";
import { mentionSkillItems } from "@/components/composer/mention-skill-source";
import { useProjectFileItems } from "@/components/composer/project-file-source";
import { slashSkillItems } from "@/components/composer/slash-skill-source";
import {
type ComposerTriggers,
type TriggerDetector,
useComposerTriggers,
} from "@/components/composer/use-composer-triggers";
import { listUserSkills, USER_SKILLS_QUERY } from "@/lib/api/skills";
import { COMPOSER_SKILLS_QUERY, fetchComposerSkills } from "@/lib/api/skills";
import { detectMentionToken, detectSlashToken } from "@/lib/input/caret-tokens";
import { emitComposerEvent } from "@/lib/telemetry/user-events";

Expand Down Expand Up @@ -39,9 +39,9 @@ export interface ComposerMenuController {
}

export function useComposerMenu(options: UseComposerMenuOptions): ComposerMenuController {
const userSkills = useQuery({
queryFn: ({ signal }) => listUserSkills(options.getToken, signal),
queryKey: USER_SKILLS_QUERY,
const skillsCatalog = useQuery({
queryFn: ({ signal }) => fetchComposerSkills(options.getToken, signal),
queryKey: COMPOSER_SKILLS_QUERY,
staleTime: 60_000,
});
const triggers = useComposerTriggers({
Expand All @@ -67,7 +67,7 @@ export function useComposerMenu(options: UseComposerMenuOptions): ComposerMenuCo
const items =
triggers.kind === "slash"
? fileItems
: skillMenuItems(triggers.query, userSkills.data ?? [], userSkills.isPending);
: skillMenuItems(triggers.query, skillsCatalog.data, skillsCatalog.isPending);
return {
ariaLabel: triggers.kind === "slash" ? "Project files" : "Skills",
handleKeyDown: (event) => triggers.handleMenuKeyDown(event, items),
Expand All @@ -91,10 +91,10 @@ function selectComposerItem(

function skillMenuItems(
query: string,
userSkills: Parameters<typeof slashSkillItems>[1],
catalog: Awaited<ReturnType<typeof fetchComposerSkills>> | undefined,
isPending: boolean,
): ComposerMenuItem[] {
const items = slashSkillItems(query, userSkills);
const items = mentionSkillItems(query, catalog?.skills, catalog?.connectedApps);
if (items.length > 0) {
return items;
}
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/lib/api/skills.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

import {
type ComposerSkillsResponse,
ComposerSkillsResponseSchema,
type SandboxIdeSession,
SandboxIdeSessionSchema,
type UserSkill,
Expand All @@ -13,8 +15,20 @@ import {
readBoundedJsonResponse,
} from "@/lib/api/authorized-fetch";

export const COMPOSER_SKILLS_QUERY = ["composer-skills"] as const;
export const USER_SKILLS_QUERY = ["user-skills"] as const;

/** Custom skills and active connected apps available to the composer. */
export async function fetchComposerSkills(
getToken: () => Promise<null | string>,
signal?: AbortSignal,
): Promise<ComposerSkillsResponse> {
const response = await authorizedFetch(getToken, "/v1/composer/skills", signal ? { signal } : {});
return ComposerSkillsResponseSchema.parse(
await readBoundedJsonResponse(response, API_RESPONSE_LIMIT_BYTES.metadata),
);
}

/** The caller's custom skills (body-less summaries). */
export async function listUserSkills(
getToken: () => Promise<null | string>,
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,5 +179,10 @@ Composio REST tool discovery and execution responses are byte-bounded before
parsing, then projected into bounded, valid JSON before entering model context.
Toolkit names use the shared open-slug contract from
`@cheatcode/types/integrations` across API, context, and tool boundaries.
Discovery first uses Composio's full-text query. Because that query can return zero for an
over-specific natural-language phrase even when the toolkit has the required action, a zero-result
search performs one broad toolkit fetch and deterministically ranks bounded candidates by the
action, object, name, slug, and description terms. The agent still executes only an exact returned
slug and concrete toolkit version.
Callers must honor the returned truncation
flag and narrow tool discovery with `search` when a schema does not fit.
Loading