diff --git a/.changeset/web-pre-session-skills.md b/.changeset/web-pre-session-skills.md new file mode 100644 index 000000000..45c26e4fd --- /dev/null +++ b/.changeset/web-pre-session-skills.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Show available skills in the composer before a session is created. diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 315c77982..5c5049b3c 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -717,8 +717,9 @@ export class DaemonKimiWebApi implements KimiWebApi { } // ------------------------------------------------------------------------- - // Skills — session-scoped slash-invocable skills + // Skills — slash-invocable skills (session- or workspace-scoped) // GET /sessions/{id}/skills → { skills: WireSkillDescriptor[] } + // GET /workspaces/{id}/skills → { skills: WireSkillDescriptor[] } (no session) // POST /sessions/{id}/skills/{name}:activate body { args? } → { activated, skill_name } // ------------------------------------------------------------------------- @@ -733,6 +734,17 @@ export class DaemonKimiWebApi implements KimiWebApi { })); } + async listSkillsForWorkspace(workspaceId: string): Promise { + const data = await this.http.get<{ skills: WireSkillDescriptor[] }>( + `/workspaces/${encodeURIComponent(workspaceId)}/skills`, + ); + return (data.skills ?? []).map((s) => ({ + name: s.name, + description: s.description, + source: s.source, + })); + } + async activateSkill( sessionId: string, skillName: string, diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index be559f3d9..16adbbcd3 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -675,6 +675,8 @@ export interface KimiWebApi { respondQuestion(sessionId: string, questionId: string, response: QuestionResponse): Promise<{ resolved: true; resolvedAt: string }>; dismissQuestion(sessionId: string, questionId: string): Promise<{ dismissed: true; dismissedAt: string }>; listSkills(sessionId: string): Promise; + /** List skills for a workspace (no session required) — GET /workspaces/{id}/skills. */ + listSkillsForWorkspace(workspaceId: string): Promise; activateSkill(sessionId: string, skillName: string, args?: string): Promise<{ activated: true; skillName: string }>; listTasks(sessionId: string, status?: AppTaskStatus): Promise; getTask(sessionId: string, taskId: string, input?: { withOutput?: boolean; outputBytes?: number }): Promise; diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index 465a4ee9b..c8409f8af 100644 --- a/apps/kimi-web/src/composables/client/useModelProviderState.ts +++ b/apps/kimi-web/src/composables/client/useModelProviderState.ts @@ -90,6 +90,9 @@ export function useModelProviderState( // Session-scoped skills (slash-invocable). Loaded lazily per session; the active // session's list feeds the composer's `/` menu. const skillsBySession = ref>({}); + // Workspace-scoped skills, used to populate the `/` menu before a session exists + // (onboarding composer). Keyed by workspace id; loaded once per workspace. + const skillsByWorkspace = ref>({}); const providers = ref([]); // Model picked while in the "new session draft" state (onboarding composer — @@ -127,6 +130,17 @@ export function useModelProviderState( } } + async function loadSkillsForWorkspace(workspaceId: string): Promise { + try { + const api = getKimiWebApi(); + const list = await api.listSkillsForWorkspace(workspaceId); + skillsByWorkspace.value = { ...skillsByWorkspace.value, [workspaceId]: list }; + } catch { + // Side data; an older daemon without /workspaces/{id}/skills just yields + // no slash-skills for the onboarding composer. + } + } + /** Load models (cached — call again to force refresh) */ async function loadModels(): Promise { try { @@ -383,8 +397,10 @@ export function useModelProviderState( providers, draftModel, skillsBySession, + skillsByWorkspace, // actions loadSkillsForSession, + loadSkillsForWorkspace, loadModels, refreshOAuthProviderModels, loadProviders, diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index ed7a0be08..9e149433e 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -1627,11 +1627,13 @@ const sessions = computed(() => { const activeSessionId = computed(() => rawState.activeSessionId ?? ''); -/** Slash-invocable skills for the active session (feeds the composer `/` menu). */ +/** Slash-invocable skills for the composer `/` menu — the active session's skills, + * or, before a session exists, the active workspace's skills. */ const skills = computed(() => { const sid = rawState.activeSessionId; - if (!sid) return []; - return modelProvider.skillsBySession.value[sid] ?? []; + if (sid) return modelProvider.skillsBySession.value[sid] ?? []; + const wid = activeWorkspaceId.value; + return wid ? (modelProvider.skillsByWorkspace.value[wid] ?? []) : []; }); const isSending = computed(() => { @@ -2048,6 +2050,21 @@ const activeWorkspaceId = computed(() => { return list[0]?.id ?? null; }); +// Pre-warm workspace-scoped skills so the onboarding composer's `/` menu is +// populated before a session exists. Loaded once per workspace (guard mirrors +// the per-session guard in refreshSessionSidecars); session skills take over +// via refreshSessionSidecars once a session is created. +watch( + activeWorkspaceId, + (id) => { + if (!id) return; + if (!Object.prototype.hasOwnProperty.call(modelProvider.skillsByWorkspace.value, id)) { + void modelProvider.loadSkillsForWorkspace(id); + } + }, + { immediate: true }, +); + /** The active workspace as a sidebar view (or null when none). */ const visibleWorkspace = computed(() => { const id = activeWorkspaceId.value; diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 87f407b59..4e1f119f2 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -274,6 +274,10 @@ export interface ActivateSkillPayload { readonly args?: string | undefined; } +export interface ListWorkspaceSkillsPayload { + readonly workDir: string; +} + export interface ActivatePluginCommandPayload { readonly pluginId: string; readonly commandName: string; @@ -453,6 +457,7 @@ export interface CoreAPI extends SessionAPIWithId { forkSession: (payload: ForkSessionPayload) => ResumeSessionResult; listSessions: (payload: ListSessionsPayload) => readonly SessionSummary[]; exportSession: (payload: ExportSessionPayload) => ExportSessionResult; + listWorkspaceSkills: (payload: ListWorkspaceSkillsPayload) => Promise; listPlugins: (payload: EmptyPayload) => readonly PluginSummary[]; installPlugin: (payload: InstallPluginPayload) => PluginSummary; setPluginEnabled: (payload: SetPluginEnabledPayload) => void; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 5866d0b46..821be6ef7 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -35,6 +35,12 @@ import type { Logger } from '../logging/types'; import { resolveSessionMcpConfig, mergeCallerMcpServers, type SessionMcpConfig } from '../mcp'; import { Session, type SessionMeta, type SessionSkillConfig } from '../session'; import { exportSessionDirectory } from '../session/export'; +import { + registerBuiltinSkills, + SessionSkillRegistry, + resolveSkillRoots, + summarizeSkill, +} from '../skill'; import { ProviderManager, type BearerTokenProvider, type OAuthTokenProviderResolver @@ -80,6 +86,7 @@ import type { GetPluginInfoPayload, InstallPluginPayload, ListSessionsPayload, + ListWorkspaceSkillsPayload, McpServerInfo, McpStartupMetrics, PluginInfo, @@ -756,6 +763,37 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).listSkills(payload); } + /** + * List the skills available for a workspace working directory without + * requiring a session. Mirrors `Session.loadSkills` exactly (same roots, + * same discovery order, same built-ins) so the result matches what a new + * session created in `workDir` would see. Used to populate the composer + * skill menu before a session exists. + */ + async listWorkspaceSkills({ + workDir, + }: ListWorkspaceSkillsPayload): Promise { + const cwd = requiredWorkDir('listWorkspaceSkills', workDir); + await this.pluginsReady; + const skills = this.resolveSessionSkillConfig(this.reloadProviderManager()); + const roots = await resolveSkillRoots({ + paths: { + userHomeDir: skills.userHomeDir ?? this.userHomeDir, + brandHomeDir: skills.brandHomeDir ?? this.homeDir, + workDir: cwd, + }, + explicitDirs: skills.explicitDirs, + extraDirs: skills.extraDirs, + pluginSkillRoots: skills.pluginSkillRoots, + mergeAllAvailableSkills: skills.mergeAllAvailableSkills, + builtinDir: skills.builtinDir, + }); + const registry = new SessionSkillRegistry({}); + await registry.loadRoots(roots); + registerBuiltinSkills(registry); + return registry.listSkills().map(summarizeSkill); + } + listPluginCommands({ sessionId, ...payload diff --git a/packages/agent-core/src/services/skill/skill.ts b/packages/agent-core/src/services/skill/skill.ts index 06adbdf33..7d6516195 100644 --- a/packages/agent-core/src/services/skill/skill.ts +++ b/packages/agent-core/src/services/skill/skill.ts @@ -7,18 +7,23 @@ * * **CoreAPI surface used**: * - `core.rpc.listSkills({sessionId}) => readonly SkillSummary[]` - * (packages/agent-core/src/rpc/core-api.ts:347, SessionAPI). + * (packages/agent-core/src/rpc/core-api.ts, SessionAPI) — session-scoped. + * - `core.rpc.listWorkspaceSkills({workDir}) => Promise` + * (CoreAPI) — workspace-cwd-scoped, no session required; mirrors the roots + * a new session would scan. * - `core.rpc.activateSkill({sessionId, agentId, name, args})` - * (line 324, AgentAPI) — renders the skill prompt and starts a turn with a + * (AgentAPI) — renders the skill prompt and starts a turn with a * `skill_activation` origin (trigger 'user-slash'), mirroring the TUI's * slash-command path. It does NOT go through `IPromptService`, so no * `prompt_id` is minted; clients observe progress via `skill.activated` + * `turn.*` events on the WS stream. * - * **Session scoping**: the skill registry is per-session (project skills are - * discovered from the session cwd), so both methods are session-scoped and the - * impl resumes the session before dispatching — sessions that exist on disk - * but are not in the active map after a daemon restart still resolve. + * **Scoping**: the skill registry is per-session (project skills are + * discovered from the session cwd). `list`/`activate` are session-scoped, so + * the impl resumes the session before dispatching — sessions that exist on + * disk but are not in the active map after a daemon restart still resolve. + * `listForWorkDir` is workspace-cwd-scoped instead: it scans the same roots a + * new session would, without creating or resuming one. * * **Error model**: * - `SkillSessionNotFoundError` is NOT defined here — the impl throws the @@ -68,6 +73,13 @@ export interface ISkillService { */ list(sessionId: string): Promise; + /** + * Return the skills available for a workspace working directory (project + + * user + extra + builtin) without requiring a session. Used to populate the + * composer skill menu before a session is created. + */ + listForWorkDir(workDir: string): Promise; + /** * Activate a skill by name in a session — the REST analogue of typing * `/ `. Starts a turn on the session's main agent. Throws diff --git a/packages/agent-core/src/services/skill/skillService.ts b/packages/agent-core/src/services/skill/skillService.ts index cc83dc798..56bc33662 100644 --- a/packages/agent-core/src/services/skill/skillService.ts +++ b/packages/agent-core/src/services/skill/skillService.ts @@ -31,6 +31,11 @@ export class SkillService extends Disposable implements ISkillService { return raw.map(toProtocolSkill); } + async listForWorkDir(workDir: string): Promise { + const raw = await this.core.rpc.listWorkspaceSkills({ workDir }); + return raw.map(toProtocolSkill); + } + async activate(sessionId: string, skillName: string, args?: string): Promise { await this._requireLoadedSession(sessionId); try { diff --git a/packages/server/src/routes/skills.ts b/packages/server/src/routes/skills.ts index cc8c876db..7389ca259 100644 --- a/packages/server/src/routes/skills.ts +++ b/packages/server/src/routes/skills.ts @@ -1,15 +1,22 @@ /** - * `/sessions/{session_id}/skills*` REST routes. + * `/skills` REST routes (session- and workspace-scoped). * - * 2 endpoints: + * 3 endpoints: * * GET /sessions/{session_id}/skills data: {skills: SkillDescriptor[]} + * GET /workspaces/{workspace_id}/skills data: {skills: SkillDescriptor[]} * POST /sessions/{session_id}/skills/{skill_name}:activate body: {args?} data: {activated: true, skill_name} * - * Skills are session-scoped: the registry is built per session (project - * skills are discovered from the session cwd), so the list lives under + * The session list is session-scoped: the registry is built per session + * (project skills are discovered from the session cwd), so it lives under * `/sessions/{session_id}` rather than as a global collection like `/tools`. * + * The workspace list (`/workspaces/{workspace_id}/skills`) is the + * session-less counterpart: it scans the same roots a new session in that + * workspace cwd would, so clients can populate the composer skill menu before + * a session exists. The workspace id is resolved to its root via + * `IWorkspaceRegistry.resolveRoot`. + * * Activation is the REST analogue of typing `/ ` in the TUI: it * renders the skill prompt and starts a turn on the session's main agent with * a `skill_activation` origin. No prompt_id is minted (the turn bypasses @@ -17,6 +24,7 @@ * `turn.*` events on the WS stream. * * **Error mapping**: + * - `WorkspaceNotFoundError` → envelope `code: 40410 workspace.not_found`. * - `SessionNotFoundError` → envelope `code: 40401 session.not_found`. * - `SkillNotFoundError` → envelope `code: 40415 skill.not_found`. * - `SkillNotActivatableError` → envelope `code: 40912 skill.not_activatable`. @@ -34,8 +42,9 @@ import { activateSkillRequestSchema, activateSkillResultSchema, listSkillsResponseSchema, + workspaceIdParamSchema, } from '@moonshot-ai/protocol'; -import { ISkillService, SessionNotFoundError, SkillNotActivatableError, SkillNotFoundError, type IInstantiationService } from '@moonshot-ai/agent-core'; +import { ISkillService, IWorkspaceRegistry, SessionNotFoundError, SkillNotActivatableError, SkillNotFoundError, WorkspaceNotFoundError, type IInstantiationService } from '@moonshot-ai/agent-core'; import { z } from 'zod'; @@ -102,6 +111,45 @@ export function registerSkillsRoutes( listSkillsRoute.handler as Parameters[2], ); + // GET /workspaces/{workspace_id}/skills -------------------------------- + const listWorkspaceSkillsRoute = defineRoute( + { + method: 'GET', + path: '/workspaces/{workspace_id}/skills', + params: workspaceIdParamSchema, + success: { data: listSkillsResponseSchema }, + errors: { + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'List the skills available to a workspace (no session required)', + tags: ['skills'], + operationId: 'listWorkspaceSkills', + }, + async (req, reply) => { + try { + const { workspace_id } = req.params; + // Resolve both services from the accessor synchronously: the DI + // accessor is only valid during the synchronous invocation of the + // callback, so no `a.get(...)` may follow an `await`. + const skills = await ix.invokeFunction((a) => { + const registry = a.get(IWorkspaceRegistry); + const skillService = a.get(ISkillService); + return registry.resolveRoot(workspace_id).then((workDir) => + skillService.listForWorkDir(workDir), + ); + }); + reply.send(okEnvelope({ skills }, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + app.get( + listWorkspaceSkillsRoute.path, + listWorkspaceSkillsRoute.options, + listWorkspaceSkillsRoute.handler as Parameters[2], + ); + // POST /sessions/{session_id}/skills/{skill_name}:activate -------------- const activateSkillRoute = defineRoute( { @@ -169,6 +217,10 @@ function sendMappedError( requestId: string, err: unknown, ): void { + if (err instanceof WorkspaceNotFoundError) { + reply.send(errEnvelope(ErrorCode.WORKSPACE_NOT_FOUND, err.message, requestId)); + return; + } if (err instanceof SessionNotFoundError) { reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId)); return; diff --git a/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap b/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap index df69bdedc..32e9e5b4f 100644 --- a/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap +++ b/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap @@ -177,6 +177,10 @@ exports[`API surface snapshot > matches the documented v1 route table and meta e "GET", "/api/v1/workspaces", ], + [ + "GET", + "/api/v1/workspaces/{workspace_id}/skills", + ], [ "GET", "/asyncapi.json", diff --git a/packages/server/test/skills.e2e.test.ts b/packages/server/test/skills.e2e.test.ts index 0db590320..cd6fd584f 100644 --- a/packages/server/test/skills.e2e.test.ts +++ b/packages/server/test/skills.e2e.test.ts @@ -129,6 +129,19 @@ async function createSession(r: RunningServer): Promise { return env.data.id; } +async function registerWorkspace(r: RunningServer): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/workspaces', + payload: { root: workspaceDir }, + }); + const env = envelopeOf<{ id: string }>(res.json()); + if (env.code !== 0 || env.data === null) { + throw new Error(`register workspace failed: ${JSON.stringify(env)}`); + } + return env.data.id; +} + /** Seed a project skill bundle at `/.kimi-code/skills//SKILL.md`. */ function seedProjectSkill(name: string, frontmatterType?: string): void { const dir = join(workspaceDir, '.kimi-code', 'skills', name); @@ -261,3 +274,50 @@ describe('POST /api/v1/sessions/{sid}/skills/{name}:activate', () => { expect(env.code).toBe(40001); }); }); + +describe('GET /api/v1/workspaces/{wid}/skills', () => { + it('lists skills for a workspace without creating a session', async () => { + seedProjectSkill('e2e-greeting'); + const r = await bootDaemon(); + const wid = await registerWorkspace(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/api/v1/workspaces/${wid}/skills`, + }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(0); + const parsed = listSkillsResponseSchema.parse(env.data); + const seeded = parsed.skills.find((s) => s.name === 'e2e-greeting'); + expect(seeded).toBeDefined(); + expect(seeded?.source).toBe('project'); + expect(seeded?.description).toBe('e2e test skill e2e-greeting'); + }); + + it('matches the session listing for the same cwd', async () => { + seedProjectSkill('e2e-greeting'); + const r = await bootDaemon(); + const wid = await registerWorkspace(r); + const sid = await createSession(r); + const [wsRes, sessRes] = await Promise.all([ + appOf(r).inject({ method: 'GET', url: `/api/v1/workspaces/${wid}/skills` }), + appOf(r).inject({ method: 'GET', url: `/api/v1/sessions/${sid}/skills` }), + ]); + const wsSkills = listSkillsResponseSchema.parse(envelopeOf(wsRes.json()).data).skills; + const sessSkills = listSkillsResponseSchema.parse( + envelopeOf(sessRes.json()).data, + ).skills; + const names = (xs: readonly { name: string }[]) => xs.map((s) => s.name).toSorted(); + expect(names(wsSkills)).toEqual(names(sessSkills)); + }); + + it('returns 40410 for an unknown workspace', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'GET', + url: '/api/v1/workspaces/wd_does-not-exist_000000000000/skills', + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40410); + }); +});