diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json
index a2843b2b3f..0cd0358709 100644
--- a/locales/en-US/chat.json
+++ b/locales/en-US/chat.json
@@ -962,7 +962,7 @@
"workingPanel.resources.deleteError": "Failed to delete document",
"workingPanel.resources.deleteSuccess": "Document deleted",
"workingPanel.resources.deleteTitle": "Delete document?",
- "workingPanel.resources.empty": "No documents yet. Documents associated with this agent will show up here.",
+ "workingPanel.resources.empty": "No webpages. Webpages crawled in this agent will show up here.",
"workingPanel.resources.error": "Failed to load resources",
"workingPanel.resources.filter.documents": "Documents",
"workingPanel.resources.filter.skills": "Skills",
@@ -1019,10 +1019,10 @@
"workingPanel.review.viewMode.unified": "Switch to unified view",
"workingPanel.review.wordWrap.disable": "Disable word wrap",
"workingPanel.review.wordWrap.enable": "Enable word wrap",
- "workingPanel.skills.empty": "No skills found in this project",
- "workingPanel.skills.emptyAgent": "No skills attached to this agent",
+ "workingPanel.skills.empty": "No skills available",
"workingPanel.skills.section.agent": "Agent skills",
"workingPanel.skills.section.project": "Project skills",
+ "workingPanel.skills.section.user": "User skills",
"workingPanel.skills.title": "Skills",
"workingPanel.space": "Space",
"workingPanel.title": "Working Panel",
diff --git a/locales/en-US/plugin.json b/locales/en-US/plugin.json
index e6997a03c7..afb7821ee2 100644
--- a/locales/en-US/plugin.json
+++ b/locales/en-US/plugin.json
@@ -264,6 +264,8 @@
"builtins.lobe-skill-store.render.repository": "Repository",
"builtins.lobe-skill-store.render.version": "Version",
"builtins.lobe-skill-store.title": "Skill Store",
+ "builtins.lobe-skills.apiName.activateAgentSkill": "Activate Agent Skill",
+ "builtins.lobe-skills.apiName.activateProjectSkill": "Activate Project Skill",
"builtins.lobe-skills.apiName.activateSkill": "Activate Skill",
"builtins.lobe-skills.apiName.execScript": "Run Script",
"builtins.lobe-skills.apiName.exportFile": "Export File",
diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json
index 6e4317babb..76d2531e77 100644
--- a/locales/zh-CN/chat.json
+++ b/locales/zh-CN/chat.json
@@ -962,7 +962,7 @@
"workingPanel.resources.deleteError": "删除失败",
"workingPanel.resources.deleteSuccess": "文档已删除",
"workingPanel.resources.deleteTitle": "删除该文档?",
- "workingPanel.resources.empty": "暂无文档。与此智能体关联的文档将显示在这里。",
+ "workingPanel.resources.empty": "暂无网页。本智能体抓取的网页将显示在这里。",
"workingPanel.resources.error": "资源加载失败",
"workingPanel.resources.filter.documents": "文档",
"workingPanel.resources.filter.skills": "技能",
@@ -1019,10 +1019,10 @@
"workingPanel.review.viewMode.unified": "切换到合并视图",
"workingPanel.review.wordWrap.disable": "关闭自动换行",
"workingPanel.review.wordWrap.enable": "启用自动换行",
- "workingPanel.skills.empty": "当前项目未发现技能",
- "workingPanel.skills.emptyAgent": "该智能体未附加技能",
+ "workingPanel.skills.empty": "暂无可用技能",
"workingPanel.skills.section.agent": "智能体技能",
"workingPanel.skills.section.project": "项目技能",
+ "workingPanel.skills.section.user": "用户技能",
"workingPanel.skills.title": "技能",
"workingPanel.space": "空间",
"workingPanel.title": "工作面板",
diff --git a/locales/zh-CN/plugin.json b/locales/zh-CN/plugin.json
index fa07b44496..380f628827 100644
--- a/locales/zh-CN/plugin.json
+++ b/locales/zh-CN/plugin.json
@@ -264,6 +264,8 @@
"builtins.lobe-skill-store.render.repository": "存储库",
"builtins.lobe-skill-store.render.version": "版本",
"builtins.lobe-skill-store.title": "技能商店",
+ "builtins.lobe-skills.apiName.activateAgentSkill": "激活 Agent Skill",
+ "builtins.lobe-skills.apiName.activateProjectSkill": "激活 Project Skill",
"builtins.lobe-skills.apiName.activateSkill": "激活技能",
"builtins.lobe-skills.apiName.execScript": "执行脚本",
"builtins.lobe-skills.apiName.exportFile": "导出文件",
diff --git a/packages/builtin-tool-activator/src/client/Inspector/ActivateSkill/index.tsx b/packages/builtin-tool-activator/src/client/Inspector/ActivateSkill/index.tsx
index ae24a890d5..976d05743c 100644
--- a/packages/builtin-tool-activator/src/client/Inspector/ActivateSkill/index.tsx
+++ b/packages/builtin-tool-activator/src/client/Inspector/ActivateSkill/index.tsx
@@ -3,12 +3,31 @@
import { type BuiltinInspectorProps } from '@lobechat/types';
import { SkillsIcon } from '@lobehub/ui/icons';
import { createStaticStyles, cx } from 'antd-style';
+import { type TFunction } from 'i18next';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
-import type { ActivateSkillParams, ActivateSkillState } from '../../../types';
+import type { ActivateSkillParams, ActivateSkillSource, ActivateSkillState } from '../../../types';
+
+/**
+ * `t` is invoked with literal keys per branch so i18next's typed-key map can
+ * still validate the call site.
+ */
+const resolveLabel = (t: TFunction<'plugin'>, source: ActivateSkillSource | undefined): string => {
+ switch (source) {
+ case 'agent': {
+ return t('builtins.lobe-skills.apiName.activateAgentSkill');
+ }
+ case 'project': {
+ return t('builtins.lobe-skills.apiName.activateProjectSkill');
+ }
+ default: {
+ return t('builtins.lobe-skills.apiName.activateSkill');
+ }
+ }
+};
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
@@ -50,19 +69,20 @@ export const ActivateSkillInspector = memo<
const { t } = useTranslation('plugin');
const name = args?.name || partialArgs?.name;
- const displayName = pluginState?.name || name;
+ const displayName = pluginState?.title || pluginState?.name || name;
+ const label = resolveLabel(t, pluginState?.source);
if (isArgumentsStreaming) {
if (!displayName)
return (
- {t('builtins.lobe-skills.apiName.activateSkill')}
+ {label}
);
return (
-
{t('builtins.lobe-skills.apiName.activateSkill')}:
+
{label}:
{displayName}
@@ -73,7 +93,7 @@ export const ActivateSkillInspector = memo<
return (
-
{t('builtins.lobe-skills.apiName.activateSkill')}:
+
{label}:
{displayName && (
diff --git a/packages/builtin-tool-activator/src/client/Render/ActivateSkill/index.tsx b/packages/builtin-tool-activator/src/client/Render/ActivateSkill/index.tsx
index 7a50a79650..6f6fadb9f6 100644
--- a/packages/builtin-tool-activator/src/client/Render/ActivateSkill/index.tsx
+++ b/packages/builtin-tool-activator/src/client/Render/ActivateSkill/index.tsx
@@ -38,14 +38,15 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
const ActivateSkill = memo>(
({ content, pluginState }) => {
- const { description, name } = pluginState || {};
+ const { description, name, title } = pluginState || {};
+ const displayName = title || name;
- if (!name) return null;
+ if (!displayName) return null;
return (
- {name}
+ {displayName}
{description && {description}}
{content && (
diff --git a/packages/builtin-tool-activator/src/index.ts b/packages/builtin-tool-activator/src/index.ts
index b0fa7c5c53..eb5b8b9621 100644
--- a/packages/builtin-tool-activator/src/index.ts
+++ b/packages/builtin-tool-activator/src/index.ts
@@ -3,6 +3,7 @@ export { systemPrompt } from './systemRole';
export {
type ActivatedToolInfo,
type ActivateSkillParams,
+ type ActivateSkillSource,
type ActivateSkillState,
type ActivateToolsParams,
type ActivateToolsState,
diff --git a/packages/builtin-tool-activator/src/types.ts b/packages/builtin-tool-activator/src/types.ts
index eb0f9aac93..4bebd2bb4b 100644
--- a/packages/builtin-tool-activator/src/types.ts
+++ b/packages/builtin-tool-activator/src/types.ts
@@ -27,9 +27,15 @@ export interface ActivateSkillParams {
name: string;
}
+export type ActivateSkillSource = 'agent' | 'builtin' | 'project' | 'user';
+
export interface ActivateSkillState {
description?: string;
hasResources: boolean;
id: string;
name: string;
+ /** Skill origin — drives the inspector label (e.g. "Activate Agent Skill"). */
+ source?: ActivateSkillSource;
+ /** Friendly title for UI display; falls back to `name` when unset. */
+ title?: string;
}
diff --git a/packages/builtin-tool-skills/src/ExecutionRuntime/index.test.ts b/packages/builtin-tool-skills/src/ExecutionRuntime/index.test.ts
index 135b8538b8..1cb9982dfd 100644
--- a/packages/builtin-tool-skills/src/ExecutionRuntime/index.test.ts
+++ b/packages/builtin-tool-skills/src/ExecutionRuntime/index.test.ts
@@ -182,4 +182,165 @@ describe('SkillsExecutionRuntime', () => {
});
});
});
+
+ describe('project skills', () => {
+ const projectSkill = {
+ location: '/repo/.agents/skills/deploy/SKILL.md',
+ name: 'deploy',
+ };
+
+ it('activateSkill reads SKILL.md and appends a directory hint for lazy discovery', async () => {
+ const readFile = vi.fn().mockResolvedValue('# Deploy\nRun the deploy steps.');
+ const listFiles = vi.fn().mockResolvedValue([]);
+ const runtime = new SkillsExecutionRuntime({
+ deviceFileAccess: { listFiles, readFile },
+ projectSkills: [projectSkill],
+ service: createMockService(),
+ });
+
+ const result = await runtime.activateSkill({ name: 'deploy' });
+
+ expect(readFile).toHaveBeenCalledWith('/repo/.agents/skills/deploy/SKILL.md');
+ expect(result.success).toBe(true);
+ expect(result.content).toContain('Run the deploy steps.');
+ // The hint points at the skill's directory and instructs the model to
+ // call `local-system.listFiles` itself rather than pre-enumerating here.
+ expect(result.content).toContain('/repo/.agents/skills/deploy');
+ expect(result.content).toContain('listFiles');
+ expect(result.state).toMatchObject({ name: 'deploy', source: 'project' });
+ });
+
+ it('activateSkill takes precedence over a same-named DB skill', async () => {
+ const readFile = vi.fn().mockResolvedValue('project content');
+ const listFiles = vi.fn().mockResolvedValue([]);
+ const findByName = vi
+ .fn()
+ .mockResolvedValue({ content: 'db content', id: 'x', name: 'deploy' });
+ const runtime = new SkillsExecutionRuntime({
+ deviceFileAccess: { listFiles, readFile },
+ projectSkills: [projectSkill],
+ service: createMockService({ findByName }),
+ });
+
+ const result = await runtime.activateSkill({ name: 'deploy' });
+
+ expect(result.content).toContain('project content');
+ expect(findByName).not.toHaveBeenCalled();
+ });
+
+ it('activateSkill fails clearly when no device file access is available', async () => {
+ const runtime = new SkillsExecutionRuntime({
+ projectSkills: [projectSkill],
+ service: createMockService(),
+ });
+
+ const result = await runtime.activateSkill({ name: 'deploy' });
+
+ expect(result.success).toBe(false);
+ expect(result.content).toContain('no device file access');
+ });
+
+ it('readReference resolves a project file relative to the SKILL.md directory', async () => {
+ const readFile = vi.fn().mockResolvedValue('print("run")');
+ const listFiles = vi.fn().mockResolvedValue(['SKILL.md', 'scripts/run.py']);
+ const runtime = new SkillsExecutionRuntime({
+ deviceFileAccess: { listFiles, readFile },
+ projectSkills: [projectSkill],
+ service: createMockService(),
+ });
+
+ const result = await runtime.readReference({ id: 'deploy', path: 'scripts/run.py' });
+
+ expect(listFiles).toHaveBeenCalledWith('/repo/.agents/skills/deploy');
+ expect(readFile).toHaveBeenCalledWith('/repo/.agents/skills/deploy/scripts/run.py');
+ expect(result.success).toBe(true);
+ expect(result.content).toBe('print("run")');
+ });
+
+ it('readReference rejects paths not in the declared skill file list', async () => {
+ const readFile = vi.fn();
+ const listFiles = vi.fn().mockResolvedValue(['SKILL.md', 'scripts/run.py']);
+ const runtime = new SkillsExecutionRuntime({
+ deviceFileAccess: { listFiles, readFile },
+ projectSkills: [projectSkill],
+ service: createMockService(),
+ });
+
+ const result = await runtime.readReference({ id: 'deploy', path: 'secrets.json' });
+
+ expect(listFiles).toHaveBeenCalledWith('/repo/.agents/skills/deploy');
+ expect(readFile).not.toHaveBeenCalled();
+ expect(result.success).toBe(false);
+ expect(result.content).toContain('Resource not found in project skill');
+ });
+
+ it('readReference rejects hidden segments before consulting the device', async () => {
+ const readFile = vi.fn();
+ const listFiles = vi.fn();
+ const runtime = new SkillsExecutionRuntime({
+ deviceFileAccess: { listFiles, readFile },
+ projectSkills: [projectSkill],
+ service: createMockService(),
+ });
+
+ const result = await runtime.readReference({ id: 'deploy', path: '.env' });
+
+ expect(listFiles).not.toHaveBeenCalled();
+ expect(readFile).not.toHaveBeenCalled();
+ expect(result.success).toBe(false);
+ expect(result.content).toContain('not a permitted skill resource');
+ });
+ });
+
+ describe('activation precedence', () => {
+ it('DB skill wins over a same-named builtin (matches injection dedupe)', async () => {
+ const findByName = vi.fn().mockResolvedValue({
+ content: 'user-authored content',
+ description: 'user skill',
+ id: 'user-1',
+ name: 'overlap',
+ });
+ const runtime = new SkillsExecutionRuntime({
+ builtinSkills: [
+ {
+ content: 'builtin content',
+ description: 'builtin skill',
+ identifier: 'overlap',
+ name: 'overlap',
+ source: 'builtin',
+ },
+ ],
+ service: createMockService({ findByName }),
+ });
+
+ const result = await runtime.activateSkill({ name: 'overlap' });
+
+ expect(findByName).toHaveBeenCalledWith('overlap');
+ expect(result.success).toBe(true);
+ expect(result.content).toContain('user-authored content');
+ expect(result.state).toMatchObject({ name: 'overlap', source: 'user' });
+ });
+
+ it('falls through to builtin when no DB skill exists', async () => {
+ const findByName = vi.fn().mockResolvedValue(undefined);
+ const runtime = new SkillsExecutionRuntime({
+ builtinSkills: [
+ {
+ content: 'builtin only',
+ description: 'builtin skill',
+ identifier: 'artifacts',
+ name: 'artifacts',
+ source: 'builtin',
+ },
+ ],
+ service: createMockService({ findByName }),
+ });
+
+ const result = await runtime.activateSkill({ name: 'artifacts' });
+
+ expect(result.success).toBe(true);
+ expect(result.content).toContain('builtin only');
+ expect(result.state).toMatchObject({ name: 'artifacts', source: 'builtin' });
+ });
+ });
});
diff --git a/packages/builtin-tool-skills/src/ExecutionRuntime/index.ts b/packages/builtin-tool-skills/src/ExecutionRuntime/index.ts
index 21084ab51d..07b0a3de66 100644
--- a/packages/builtin-tool-skills/src/ExecutionRuntime/index.ts
+++ b/packages/builtin-tool-skills/src/ExecutionRuntime/index.ts
@@ -1,3 +1,4 @@
+import { AGENT_SKILLS_IDENTIFIER_PREFIX } from '@lobechat/const';
import { formatCommandResult, resourcesTreePrompt } from '@lobechat/prompts';
import type {
BuiltinServerRuntimeOutput,
@@ -52,18 +53,96 @@ export interface SkillRuntimeService {
runCommand?: (options: RunCommandOptions) => Promise;
}
+/**
+ * A project-level skill discovered on the device filesystem
+ * (`.agents/skills` / `.claude/skills`). The runtime only needs the name to
+ * match an `activateSkill`/`readReference` call and the absolute SKILL.md path
+ * to read its content. The directory tree is enumerated lazily on activation
+ * via `DeviceFileAccess.listFiles` — keeping it out of the op param payload.
+ */
+export interface ProjectSkillRuntimeItem {
+ /** Absolute path to the skill's SKILL.md on the device. */
+ location: string;
+ name: string;
+}
+
+/**
+ * Device filesystem access used to load project skills. The server wires this
+ * to the `local-system` tool over the device gateway, so the runtime stays
+ * transport-agnostic — it just needs to read/enumerate files on the device.
+ */
+export interface DeviceFileAccess {
+ /**
+ * Recursively enumerate files under `dir`, returning POSIX-style paths
+ * relative to `dir`. `readReference` validates user-supplied paths against
+ * this list so the model can only read files the project skill actually
+ * exposes (no hidden files, no escape outside the skill directory).
+ */
+ listFiles: (dir: string) => Promise;
+ /** Read a text file's content from the device. */
+ readFile: (path: string) => Promise;
+}
+
export interface SkillsExecutionRuntimeOptions {
builtinSkills?: BuiltinSkill[];
+ /** Reads project skill files from the device (local-system over the gateway). */
+ deviceFileAccess?: DeviceFileAccess;
+ /** Project skills discovered on the device filesystem. */
+ projectSkills?: ProjectSkillRuntimeItem[];
service: SkillRuntimeService;
}
+/** Cross-platform dirname for absolute paths (POSIX or Windows separators). */
+const getDirname = (filePath: string): string => {
+ const idx = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
+ return idx === -1 ? '' : filePath.slice(0, idx);
+};
+
+/** Join a directory with a relative path, preserving the directory's separator. */
+const joinPath = (dir: string, rel: string): string => {
+ const sep = dir.includes('\\') && !dir.includes('/') ? '\\' : '/';
+ const trimmed = dir.endsWith(sep) ? dir.slice(0, -sep.length) : dir;
+ return `${trimmed}${sep}${rel}`;
+};
+
+/**
+ * Normalize a user-supplied relative path to POSIX form: backslashes → `/`,
+ * trim leading `./` and slashes. Used to compare requested paths against the
+ * skill's `listFiles` result (which is canonicalized the same way by the
+ * device-side enumerator) and to detect hidden segments.
+ */
+const normalizeRelativePath = (rel: string): string =>
+ rel
+ .replaceAll('\\', '/')
+ .replace(/^(?:\.\/)+/, '')
+ .replace(/^\/+/, '');
+
+/** True when any segment in `rel` is hidden (starts with `.`) — `.env`, `.git/...`. */
+const hasHiddenSegment = (rel: string): boolean =>
+ rel.split('/').some((seg) => seg.startsWith('.'));
+
+/**
+ * Hint appended to activated project-skill content so the model knows how to
+ * discover the rest of the skill's directory. We deliberately don't enumerate
+ * the tree here — the model has `local-system.listFiles` available and can
+ * call it on demand, which keeps the op-param payload small.
+ */
+const buildProjectDirectoryHint = (skillName: string, skillDir: string): string =>
+ `## Skill resources
+
+This project skill lives in \`${skillDir}\`. Use \`local-system.listFiles\` on that path to discover reference files, then \`readReference\` with skillName="${skillName}" + the relative path to load any of them.`;
+
export class SkillsExecutionRuntime {
private builtinSkills: BuiltinSkill[];
+ private projectSkills: ProjectSkillRuntimeItem[];
+ private deviceFileAccess?: DeviceFileAccess;
private service: SkillRuntimeService;
constructor(options: SkillsExecutionRuntimeOptions) {
this.service = options.service;
this.builtinSkills = options.builtinSkills || [];
+ this.projectSkills = options.projectSkills || [];
+ this.deviceFileAccess = options.deviceFileAccess;
}
async execScript(args: ExecScriptParams): Promise {
@@ -168,16 +247,87 @@ export class SkillsExecutionRuntime {
async readReference(args: ReadReferenceParams): Promise {
const { id, path } = args;
- // Validate path to prevent traversal attacks
- if (path.includes('..')) {
- return {
- content: 'Invalid path: path traversal is not allowed',
- success: false,
- };
- }
-
try {
- // Check builtin skills first
+ // Project skills resolve references relative to the SKILL.md directory,
+ // read through the device file access (local-system over the gateway).
+ const projectSkill = this.projectSkills.find((s) => s.name === id);
+ if (projectSkill) {
+ if (!this.deviceFileAccess) {
+ return {
+ content: `Project skill "${id}" cannot be read: no device file access available.`,
+ success: false,
+ };
+ }
+
+ // Normalize and reject obviously-unsafe shapes up front. The
+ // `listFiles` membership check below is the real authority, but
+ // failing fast here keeps the error message specific.
+ const normalized = normalizeRelativePath(path);
+ if (!normalized || normalized.includes('..') || hasHiddenSegment(normalized)) {
+ return {
+ content: `Invalid path: "${path}" is not a permitted skill resource`,
+ success: false,
+ };
+ }
+
+ // Enumerate the skill directory and only allow files the device
+ // surface advertises. Without this, a model could request any path
+ // under the skill dir (e.g. `.env`, `node_modules/…`) that was never
+ // declared as a skill resource. The device-side enumerator already
+ // filters hidden files; we re-check here as defense in depth.
+ const skillDir = getDirname(projectSkill.location);
+ const allowed = new Set(
+ (await this.deviceFileAccess.listFiles(skillDir)).map((f) => normalizeRelativePath(f)),
+ );
+ if (!allowed.has(normalized)) {
+ return {
+ content: `Resource not found in project skill "${id}": "${path}"`,
+ success: false,
+ };
+ }
+
+ const fullPath = joinPath(skillDir, normalized);
+ const content = await this.deviceFileAccess.readFile(fullPath);
+ return {
+ content,
+ state: { encoding: 'utf8', fileType: 'text/plain', fullPath, path: normalized },
+ success: true,
+ };
+ }
+
+ // For non-project skills, keep the traversal guard. Builtin / user
+ // skills look paths up via an explicit `resources` map or service, so
+ // the `..` substring is the only realistic traversal vector.
+ if (path.includes('..')) {
+ return {
+ content: 'Invalid path: path traversal is not allowed',
+ success: false,
+ };
+ }
+
+ // DB (user-level) skills win over builtins on name collision — matches
+ // the `` dedupe precedence (project > user > agent >
+ // builtin) in `aiAgent/index.ts`. Without this, the model would see a
+ // user skill in the list but `readReference` would silently read the
+ // shadowed builtin's resources.
+ const skill = await this.service.findByName(id);
+ if (skill) {
+ const resource = await this.service.readResource(skill.id, path);
+ return {
+ content: resource.content,
+ state: {
+ encoding: resource.encoding,
+ fileType: resource.fileType,
+ fullPath: resource.fullPath,
+ path: resource.path,
+ size: resource.size,
+ },
+ success: true,
+ };
+ }
+
+ // Fall back to builtin skills (includes agent-document skill bundles
+ // via the `agent-skills:` identifier prefix).
const builtinSkill = this.builtinSkills.find((s) => s.name === id);
if (builtinSkill?.resources) {
const meta = builtinSkill.resources[path];
@@ -199,26 +349,9 @@ export class SkillsExecutionRuntime {
};
}
- // Resolve id: try findByName for DB skills
- const skill = await this.service.findByName(id);
- if (!skill) {
- return {
- content: `Skill not found: "${id}"`,
- success: false,
- };
- }
-
- const resource = await this.service.readResource(skill.id, path);
return {
- content: resource.content,
- state: {
- encoding: resource.encoding,
- fileType: resource.fileType,
- fullPath: resource.fullPath,
- path: resource.path,
- size: resource.size,
- },
- success: true,
+ content: `Skill not found: "${id}"`,
+ success: false,
};
} catch (e) {
return {
@@ -231,7 +364,75 @@ export class SkillsExecutionRuntime {
async activateSkill(args: ActivateSkillParams): Promise {
const { name } = args;
- // Check builtin skills first — no DB query needed
+ // Project skills (filesystem SKILL.md) take precedence over db/builtin.
+ const projectSkill = this.projectSkills.find((s) => s.name === name);
+ if (projectSkill) {
+ if (!this.deviceFileAccess) {
+ return {
+ content: `Project skill "${name}" cannot be loaded: no device file access available.`,
+ success: false,
+ };
+ }
+
+ try {
+ let content = await this.deviceFileAccess.readFile(projectSkill.location);
+
+ // Don't enumerate the directory here — let the model do it on demand
+ // via `local-system.listFiles`. Just point at the skill's directory so
+ // it knows where to look. Keeps the op-param payload small and avoids
+ // a second deviceProxy round-trip at activation time.
+ const skillDir = getDirname(projectSkill.location);
+ if (skillDir) {
+ content += '\n\n' + buildProjectDirectoryHint(name, skillDir);
+ }
+
+ return {
+ content,
+ state: {
+ hasResources: false,
+ location: projectSkill.location,
+ name,
+ source: 'project',
+ },
+ success: true,
+ };
+ } catch (e) {
+ return {
+ content: `Failed to load project skill "${name}": ${(e as Error).message}`,
+ success: false,
+ };
+ }
+ }
+
+ // DB (user-level) skills win over builtins on name collision — matches
+ // the `` dedupe precedence (project > user > agent >
+ // builtin) in `aiAgent/index.ts`. Without this, the model would see a
+ // user skill in the list but `activateSkill` would silently load the
+ // shadowed builtin instead.
+ const skill = await this.service.findByName(name);
+ if (skill) {
+ const hasResources = !!(skill.resources && Object.keys(skill.resources).length > 0);
+ let content = skill.content || '';
+
+ if (hasResources && skill.resources) {
+ content += '\n\n' + resourcesTreePrompt(skill.name, skill.resources);
+ }
+
+ return {
+ content,
+ state: {
+ description: skill.description || undefined,
+ hasResources,
+ id: skill.id,
+ name: skill.name,
+ source: 'user',
+ },
+ success: true,
+ };
+ }
+
+ // Fall back to builtin skills (includes agent-document skill bundles via
+ // the `agent-skills:` identifier prefix).
const builtinSkill = this.builtinSkills.find((s) => s.name === name);
if (builtinSkill) {
let content = builtinSkill.content;
@@ -243,6 +444,12 @@ export class SkillsExecutionRuntime {
content += '\n\n' + resourcesTreePrompt(builtinSkill.name, builtinSkill.resources);
}
+ // Agent-document skill bundles flow through the builtin path with the
+ // `agent-skills:` prefix on their identifier. Tag the result so the
+ // inspector can pick the right label ("Activate Agent Skill") and prefer
+ // the friendly `title` over the raw `agent-skills:` name.
+ const isAgentSkill = builtinSkill.identifier.startsWith(AGENT_SKILLS_IDENTIFIER_PREFIX);
+
return {
content,
state: {
@@ -250,43 +457,22 @@ export class SkillsExecutionRuntime {
hasResources,
identifier: builtinSkill.identifier,
name: builtinSkill.name,
+ source: isAgentSkill ? 'agent' : 'builtin',
+ ...(builtinSkill.title && { title: builtinSkill.title }),
},
success: true,
};
}
- // Fall back to DB query for user/market skills
- const skill = await this.service.findByName(name);
-
- if (!skill) {
- const { data: allSkills } = await this.service.findAll();
- const availableSkills = allSkills.map((s) => ({
- description: s.description,
- name: s.name,
- }));
-
- return {
- content: `Skill not found: "${name}". Available skills: ${JSON.stringify(availableSkills)}`,
- success: false,
- };
- }
-
- const hasResources = !!(skill.resources && Object.keys(skill.resources).length > 0);
- let content = skill.content || '';
-
- if (hasResources && skill.resources) {
- content += '\n\n' + resourcesTreePrompt(skill.name, skill.resources);
- }
+ const { data: allSkills } = await this.service.findAll();
+ const availableSkills = allSkills.map((s) => ({
+ description: s.description,
+ name: s.name,
+ }));
return {
- content,
- state: {
- description: skill.description || undefined,
- hasResources,
- id: skill.id,
- name: skill.name,
- },
- success: true,
+ content: `Skill not found: "${name}". Available skills: ${JSON.stringify(availableSkills)}`,
+ success: false,
};
}
diff --git a/packages/builtin-tool-skills/src/client/Inspector/RunSkill/index.tsx b/packages/builtin-tool-skills/src/client/Inspector/RunSkill/index.tsx
index fe8cfb04f2..84d9d81c59 100644
--- a/packages/builtin-tool-skills/src/client/Inspector/RunSkill/index.tsx
+++ b/packages/builtin-tool-skills/src/client/Inspector/RunSkill/index.tsx
@@ -1,14 +1,47 @@
'use client';
+import { AGENT_SKILLS_IDENTIFIER_PREFIX } from '@lobechat/const';
import { type BuiltinInspectorProps } from '@lobechat/types';
import { SkillsIcon } from '@lobehub/ui/icons';
import { createStaticStyles, cx } from 'antd-style';
+import { type TFunction } from 'i18next';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
-import type { ActivateSkillParams, ActivateSkillState } from '../../../types';
+import type { ActivateSkillParams, ActivateSkillSource, ActivateSkillState } from '../../../types';
+
+/**
+ * Resolve the inspector label. State-side `source` is the authority once the
+ * tool result has streamed in; while args are still streaming we only have the
+ * raw `name` to go on, so detect agent skills via the identifier prefix as a
+ * best-effort fallback. Project skills can't be inferred from the bare name
+ * (no prefix), so they show "Activate Skill" until the result lands.
+ *
+ * `t` is invoked with literal keys per branch so i18next's typed-key map can
+ * still validate the call site.
+ */
+const resolveLabel = (
+ t: TFunction<'plugin'>,
+ source: ActivateSkillSource | undefined,
+ rawName: string | undefined,
+): string => {
+ const effective: ActivateSkillSource =
+ source ?? (rawName?.startsWith(AGENT_SKILLS_IDENTIFIER_PREFIX) ? 'agent' : 'builtin');
+
+ switch (effective) {
+ case 'agent': {
+ return t('builtins.lobe-skills.apiName.activateAgentSkill');
+ }
+ case 'project': {
+ return t('builtins.lobe-skills.apiName.activateProjectSkill');
+ }
+ default: {
+ return t('builtins.lobe-skills.apiName.activateSkill');
+ }
+ }
+};
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
@@ -50,19 +83,20 @@ export const RunSkillInspector = memo<
const { t } = useTranslation('plugin');
const name = args?.name || partialArgs?.name;
- const displayName = pluginState?.name || name;
+ const displayName = pluginState?.title || pluginState?.name || name;
+ const label = resolveLabel(t, pluginState?.source, name);
if (isArgumentsStreaming) {
if (!displayName)
return (
- {t('builtins.lobe-skills.apiName.activateSkill')}
+ {label}
);
return (
-
{t('builtins.lobe-skills.apiName.activateSkill')}:
+
{label}:
{displayName}
@@ -73,7 +107,7 @@ export const RunSkillInspector = memo<
return (
-
{t('builtins.lobe-skills.apiName.activateSkill')}:
+
{label}:
{displayName && (
diff --git a/packages/builtin-tool-skills/src/client/Render/RunSkill/index.tsx b/packages/builtin-tool-skills/src/client/Render/RunSkill/index.tsx
index 3aea3b80b3..f44bd043c5 100644
--- a/packages/builtin-tool-skills/src/client/Render/RunSkill/index.tsx
+++ b/packages/builtin-tool-skills/src/client/Render/RunSkill/index.tsx
@@ -38,14 +38,15 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
const RunSkill = memo>(
({ content, pluginState }) => {
- const { description, name } = pluginState || {};
+ const { description, name, title } = pluginState || {};
+ const displayName = title || name;
- if (!name) return null;
+ if (!displayName) return null;
return (
- {name}
+ {displayName}
{description && {description}}
{content && (
diff --git a/packages/builtin-tool-skills/src/index.ts b/packages/builtin-tool-skills/src/index.ts
index 631b42cd85..ebac11ed0b 100644
--- a/packages/builtin-tool-skills/src/index.ts
+++ b/packages/builtin-tool-skills/src/index.ts
@@ -2,6 +2,8 @@ export { SkillsManifest } from './manifest';
export { systemPrompt } from './systemRole';
export {
type ActivateSkillParams,
+ type ActivateSkillSource,
+ type ActivateSkillState,
type CommandResult,
type ExecScriptActivatedSkill,
type ExecScriptParams,
diff --git a/packages/builtin-tool-skills/src/types.ts b/packages/builtin-tool-skills/src/types.ts
index fa15a008b3..42b44ec08e 100644
--- a/packages/builtin-tool-skills/src/types.ts
+++ b/packages/builtin-tool-skills/src/types.ts
@@ -12,11 +12,17 @@ export interface ActivateSkillParams {
name: string;
}
+export type ActivateSkillSource = 'agent' | 'builtin' | 'project' | 'user';
+
export interface ActivateSkillState {
description?: string;
hasResources: boolean;
id: string;
name: string;
+ /** Skill origin — drives the inspector label (e.g. "Activate Agent Skill"). */
+ source?: ActivateSkillSource;
+ /** Friendly title for UI display; falls back to `name` when unset. */
+ title?: string;
}
/**
diff --git a/packages/context-engine/src/providers/SkillContextProvider.ts b/packages/context-engine/src/providers/SkillContextProvider.ts
index d6c774329d..f91bf32648 100644
--- a/packages/context-engine/src/providers/SkillContextProvider.ts
+++ b/packages/context-engine/src/providers/SkillContextProvider.ts
@@ -1,4 +1,4 @@
-import { type SkillItem, skillsPrompts } from '@lobechat/prompts';
+import { type SkillItem, type SkillSource, skillsPrompts } from '@lobechat/prompts';
import debug from 'debug';
import { BaseSystemRoleProvider } from '../base/BaseSystemRoleProvider';
@@ -34,6 +34,11 @@ export interface SkillMeta {
identifier: string;
location?: string;
name: string;
+ /**
+ * Skill origin. `project` skills are discovered on the device filesystem and
+ * loaded on demand via the readFile tool (see `location`).
+ */
+ source?: SkillSource;
}
/**
@@ -88,6 +93,7 @@ export class SkillContextProvider extends BaseSystemRoleProvider {
identifier: skill.identifier,
location: skill.location,
name: skill.name,
+ source: skill.source,
}));
const availableSkillsContent = skillsPrompts(skills);
diff --git a/packages/prompts/src/prompts/skills/index.ts b/packages/prompts/src/prompts/skills/index.ts
index 4686bf8d44..857762cb46 100644
--- a/packages/prompts/src/prompts/skills/index.ts
+++ b/packages/prompts/src/prompts/skills/index.ts
@@ -1,25 +1,40 @@
export { buildResourcesTreeText, resourcesTreePrompt } from './resourcesTree';
+export type SkillSource = 'builtin' | 'project' | 'user';
+
export interface SkillItem {
description: string;
identifier: string;
location?: string;
name: string;
+ /**
+ * Where the skill comes from. `project` skills live on the device filesystem
+ * (e.g. `.agents/skills//SKILL.md`) and `location` carries their absolute
+ * path so the model can load them via the readFile tool.
+ */
+ source?: SkillSource;
}
-export const skillPrompt = (skill: SkillItem) =>
- skill.location
- ? ` ${skill.description}`
- : ` ${skill.description}`;
+export const skillPrompt = (skill: SkillItem) => {
+ const attrs = [`name="${skill.name}"`];
+ if (skill.source) attrs.push(`source="${skill.source}"`);
+ if (skill.location) attrs.push(`location="${skill.location}"`);
+ return ` ${skill.description}`;
+};
export const skillsPrompts = (skills: SkillItem[]) => {
if (skills.length === 0) return '';
const skillTags = skills.map((skill) => skillPrompt(skill)).join('\n');
+ const hasProjectSkill = skills.some((skill) => skill.source === 'project');
+ const projectHint = hasProjectSkill
+ ? `\nFor a skill with source="project", load it by calling the readFile tool on its \`location\` path before following its instructions.`
+ : '';
+
return `
${skillTags}
-Use the runSkill tool to activate a skill when needed.`;
+Use the runSkill tool to activate a skill when needed.${projectHint}`;
};
diff --git a/packages/types/src/agentExecution/index.ts b/packages/types/src/agentExecution/index.ts
index c7902c9d70..0ed28801a9 100644
--- a/packages/types/src/agentExecution/index.ts
+++ b/packages/types/src/agentExecution/index.ts
@@ -31,6 +31,22 @@ export interface ExecAgentAppContext {
topicId?: string | null;
}
+/**
+ * A project-level skill discovered on the device filesystem
+ * (`.agents/skills` / `.claude/skills`) by the client at request time.
+ * Only frontmatter + the absolute SKILL.md path are carried; the SKILL.md
+ * body and directory tree are loaded on demand at activation time via the
+ * readFile / listFiles tools.
+ */
+export interface ProjectSkillMeta {
+ /** Skill description from SKILL.md frontmatter. */
+ description?: string;
+ /** Skill name from frontmatter (falls back to the directory name). */
+ name: string;
+ /** Absolute path to the skill's SKILL.md on the device filesystem. */
+ path: string;
+}
+
/**
* Parameters for execAgent - execute a single Agent
* Either agentId or slug must be provided
@@ -71,6 +87,13 @@ export interface ExecAgentParams {
* sub-tree back to its root.
*/
parentOperationId?: string;
+ /**
+ * Project-level skills discovered on the device filesystem
+ * (`.agents/skills` / `.claude/skills`) at request time. Surfaced in the
+ * `` list and loaded on demand via the readFile tool.
+ * Only applied when a device is active for this run.
+ */
+ projectSkills?: ProjectSkillMeta[];
/** The user input/prompt */
prompt: string;
/** Override the agent's default provider */
diff --git a/packages/types/src/skill/index.ts b/packages/types/src/skill/index.ts
index a146bb770e..85b85fdceb 100644
--- a/packages/types/src/skill/index.ts
+++ b/packages/types/src/skill/index.ts
@@ -53,6 +53,14 @@ export interface BuiltinSkill {
*/
resources?: Record;
source: 'builtin';
+ /**
+ * Optional friendly title for UI display. When unset, the inspector and
+ * render layers fall back to `name` (which carries the raw identifier).
+ * Agent-document skill bundles (`agent-skills:`) set this so the
+ * activateSkill result shows e.g. "LOBE Annotation Cleanup" instead of
+ * the raw `agent-skills:lobe-annotation-cleanup`.
+ */
+ title?: string;
}
// ===== Skill Source =====
diff --git a/src/features/ChatInput/InputEditor/ActionTag/ActionTagPlugin.ts b/src/features/ChatInput/InputEditor/ActionTag/ActionTagPlugin.ts
index 593c68eafa..39cd102176 100644
--- a/src/features/ChatInput/InputEditor/ActionTag/ActionTagPlugin.ts
+++ b/src/features/ChatInput/InputEditor/ActionTag/ActionTagPlugin.ts
@@ -59,21 +59,23 @@ export class ActionTagPlugin {
// Wire format collapses to ; the `agent-skills:`
// prefix in the identifier is what the runtime keys off to
// route the activation through agentDocumentsService.
+ // ProjectSkill →
+ // Same wire format as a registered skill — the project
+ // skill is in the runtime's `` registry
+ // (added on the server when a device is active), so the
+ // model resolves it through `activateSkill` like any
+ // other. Keeps the rendered prompt uniform across skill
+ // sources, which the LiteXML round-trip preserves via the
+ // category-aware `` save format.
// Tools →
- // ProjectSkill → bare label text (e.g. `/local-testing`) so the downstream
- // CLI agent recognizes its own slash-style skill invocation
// Commands →
mdService?.registerMarkdownWriter(ActionTagNode.getType(), (ctx: any, node: any) => {
if ($isActionTagNode(node)) {
const cat = node.actionCategory;
- if (cat === 'skill' || cat === 'agentSkill') {
+ if (cat === 'skill' || cat === 'agentSkill' || cat === 'projectSkill') {
ctx.appendLine(``);
} else if (cat === 'tool') {
ctx.appendLine(``);
- } else if (cat === 'projectSkill') {
- // Chip / menu render the bare skill name; the slash is added here so
- // the downstream CLI sees `/skill-name` as a slash invocation.
- ctx.appendLine(`/${node.actionType}`);
} else {
ctx.appendLine(
``,
diff --git a/src/features/ChatInput/InputEditor/ActionTag/useSlashActionItems.ts b/src/features/ChatInput/InputEditor/ActionTag/useSlashActionItems.ts
index a1c862b140..6dfbfe68cb 100644
--- a/src/features/ChatInput/InputEditor/ActionTag/useSlashActionItems.ts
+++ b/src/features/ChatInput/InputEditor/ActionTag/useSlashActionItems.ts
@@ -2,6 +2,7 @@ import { isDesktop } from '@lobechat/const';
import { type ListProjectSkillsResult, type ProjectSkillItem } from '@lobechat/electron-client-ipc';
import type { IEditor, SlashOptions } from '@lobehub/editor';
import { SkillsIcon } from '@lobehub/ui/icons';
+import isEqual from 'fast-deep-equal';
import Fuse from 'fuse.js';
import { $getSelection, $isRangeSelection } from 'lexical';
import { ArchiveIcon, MessageSquarePlusIcon } from 'lucide-react';
@@ -14,6 +15,9 @@ import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
+import { useToolStore } from '@/store/tool';
+import { agentDocumentSkillsSelectors } from '@/store/tool/selectors';
+import type { AgentDocumentSkillItem } from '@/store/tool/slices/agentDocumentSkills/initialState';
import { useAgentId } from '../../hooks/useAgentId';
import { useChatInputStore } from '../../store';
@@ -41,26 +45,33 @@ export const useSlashActionItems = (): SlashOptions['items'] => {
const editorInstance = useChatInputStore((s) => s.editor);
const activeTopicId = useChatStore((s) => s.activeTopicId);
- // Resolve hetero-agent working directory so we can surface its project skills.
- // Topic-level override takes precedence over the agent's configured cwd.
+ // Resolve the active working directory so we can surface filesystem project
+ // skills. Topic-level override takes precedence over the agent's configured
+ // cwd. Both homogeneous and heterogeneous runtimes accept project skills now
+ // (see commit dd4a4e7595), so we no longer gate on the hetero provider.
const agentId = useAgentId();
- const isHetero = useAgentStore((s) =>
- agentId ? !!agentByIdSelectors.getAgencyConfigById(agentId)(s)?.heterogeneousProvider : false,
- );
const agentWorkingDirectory = useAgentStore((s) =>
agentId ? agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s) : undefined,
);
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
const workingDirectory = topicWorkingDirectory || agentWorkingDirectory;
- const skillsEnabled = isDesktop && isHetero && !!workingDirectory;
+ const projectSkillsEnabled = isDesktop && !!workingDirectory;
const { data: projectSkillsData } = useClientDataSWR(
- skillsEnabled ? ['project-skills', workingDirectory] : null,
+ projectSkillsEnabled ? ['project-skills', workingDirectory] : null,
() => localFileService.listProjectSkills({ scope: workingDirectory! }),
{ revalidateOnFocus: false, shouldRetryOnError: false },
);
const projectSkills = projectSkillsData?.skills;
+ // Agent-document skill bundles (the "Agent skills" group in the working
+ // sidebar). Share the SWR key with the sidebar fetch so we don't double-fetch.
+ useToolStore((s) => s.useFetchAgentDocumentSkills)(agentId);
+ const agentDocumentSkills = useToolStore(
+ agentDocumentSkillsSelectors.getAgentDocumentSkills,
+ isEqual,
+ );
+
// Installed skills shared with the @ mention menu (builtin / lobehub / market / user agent skills).
// Tools intentionally stay out of slash — they remain @-mention only.
const installedSkillsAndTools = useInstalledSkillsAndTools();
@@ -126,6 +137,30 @@ export const useSlashActionItems = (): SlashOptions['items'] => {
},
});
+ const makeAgentSkillItem = (skill: AgentDocumentSkillItem): SlashMenuOption => {
+ const label = skill.title || skill.name;
+ return {
+ icon: SkillsIcon,
+ // Identifier already carries the `agent-skills:` prefix, which keeps it
+ // unique against project / builtin skills.
+ key: `agent-skill-${skill.identifier}`,
+ label,
+ metadata: {
+ category: 'agentSkill',
+ description: skill.description,
+ type: skill.identifier,
+ },
+ onSelect: (editor: IEditor) => {
+ const payload: InsertActionTagPayload = {
+ category: 'agentSkill',
+ label,
+ type: skill.identifier,
+ };
+ editor.dispatchCommand(INSERT_ACTION_TAG_COMMAND, payload);
+ },
+ };
+ };
+
// Trigger position:
// - line-start → commands + installed skills + project skills
// - mid-line w/ preceding whitespace → installed skills + project skills only (no commands)
@@ -175,7 +210,16 @@ export const useSlashActionItems = (): SlashOptions['items'] => {
allItems.push(makeSkillItem(skill) as SlashItem);
}
- // Hetero-agent project skills (file-system based, resolved by the CLI agent itself)
+ // Agent-document skill bundles (per-agent, resolved server-side via the
+ // `agent-skills:` identifier prefix).
+ for (const skill of agentDocumentSkills) {
+ allItems.push(makeAgentSkillItem(skill) as SlashItem);
+ }
+
+ // Filesystem project skills (`.agents/skills/` / `.claude/skills/` under
+ // the working directory). Both homogeneous and heterogeneous runtimes
+ // resolve them — the homogeneous runtime treats them as additional
+ // `` entries.
if (projectSkills && projectSkills.length > 0) {
for (const skill of projectSkills) {
allItems.push(makeProjectSkillItem(skill) as SlashItem);
@@ -190,6 +234,6 @@ export const useSlashActionItems = (): SlashOptions['items'] => {
return allItems;
},
- [t, editorInstance, activeTopicId, projectSkills, installedSkills],
+ [t, editorInstance, activeTopicId, projectSkills, installedSkills, agentDocumentSkills],
);
};
diff --git a/src/features/SkillsList/SkillsList.tsx b/src/features/SkillsList/SkillsList.tsx
index 81b474412c..1c7f55d376 100644
--- a/src/features/SkillsList/SkillsList.tsx
+++ b/src/features/SkillsList/SkillsList.tsx
@@ -7,8 +7,16 @@ import { memo, useCallback, useMemo, useState } from 'react';
export interface SkillListItem {
description?: string;
- fileCount: number;
- files: string[];
+ /**
+ * File count shown next to the name. Omit (or pass 0) for atomic skills with
+ * no embedded files — e.g. user-level skills sourced from MCP servers.
+ */
+ fileCount?: number;
+ /**
+ * Optional file tree for skills that bundle markdown/script assets. When
+ * empty or omitted the row stays atomic (no chevron, no expansion).
+ */
+ files?: string[];
id: string;
name: string;
}
@@ -221,7 +229,9 @@ interface SkillRowProps {
const SkillRow = memo(
({ expanded, item, onDragStart, onOpenFile, onOpenSkill, onToggle }) => {
- const tree = useMemo(() => buildSkillTree(item.files), [item.files]);
+ const files = item.files ?? [];
+ const hasFiles = files.length > 0;
+ const tree = useMemo(() => buildSkillTree(files), [files]);
const [expandedFolders, setExpandedFolders] = useState>(() => new Set());
const toggleFolder = useCallback((folderPath: string) => {
@@ -251,29 +261,36 @@ const SkillRow = memo(
gap={6}
onDragStart={onDragStart}
>
- {
- e.stopPropagation();
- onToggle();
- }}
- >
-
-
+ {hasFiles ? (
+ {
+ e.stopPropagation();
+ onToggle();
+ }}
+ >
+
+
+ ) : (
+
+ )}
{item.name}
- {item.fileCount}
+ {typeof item.fileCount === 'number' && item.fileCount > 0 && (
+ {item.fileCount}
+ )}
{expanded &&
+ hasFiles &&
onOpenFile &&
tree.map((node) => (
({
'workingPanel.resources.filter.web': 'Web',
'workingPanel.resources.updatedAt': `Updated ${options?.time}`,
'workingPanel.skills.empty': 'No skills found',
+ 'workingPanel.skills.section.agent': 'Agent skills',
+ 'workingPanel.skills.section.project': 'Project skills',
+ 'workingPanel.skills.section.user': 'User skills',
}) as Record
)[key] || key,
}),
@@ -89,7 +92,7 @@ vi.mock('@/features/AgentDocumentsExplorer', () => ({
}));
vi.mock('@/features/SkillsList', () => {
- type Item = { fileCount: number; id: string; name: string };
+ type Item = { fileCount?: number; id: string; name: string };
const SkillsList = ({
items,
onOpenFile,
@@ -114,15 +117,19 @@ vi.mock('@/features/SkillsList', () => {
))}
);
- // SkillSection is a thin presentational wrapper — the real one only adds
- // an Accordion / header. For these tests we pass children through and
- // surface the count so assertions that key off the section header still
- // work.
+ // SkillSection is a thin presentational wrapper — the real one swaps the
+ // children for an empty placeholder when `isEmpty` is true. We mirror that
+ // here so co-located assertions (e.g. "skills-list does not render when no
+ // bundles are present") line up with production behavior.
const SkillSection = ({
children,
+ emptyText,
+ isEmpty,
sectionHeader,
}: {
children?: ReactNode;
+ emptyText?: string;
+ isEmpty?: boolean;
sectionHeader?: { count?: number; title: string };
}) => (
@@ -132,7 +139,7 @@ vi.mock('@/features/SkillsList', () => {
{typeof sectionHeader.count === 'number' && {sectionHeader.count}}
)}
- {children}
+ {isEmpty ? {emptyText}
: children}
);
const useProjectSkills = () => ({
@@ -145,6 +152,19 @@ vi.mock('@/features/SkillsList', () => {
return { SkillSection, SkillsList, useProjectSkills };
});
+// UserLevelSkills owns its own store wiring and is exercised separately. We
+// stub it (component + the lifted hook) so AgentDocumentsGroup's render logic
+// can be tested without dragging the tool store into the working-sidebar
+// tests. Default to an empty user-skill list so the empty-state branch is
+// reachable; individual tests can re-mock before render to override.
+vi.mock('./UserLevelSkills', () => ({
+ default: () => null,
+ useUserSkills: () => [],
+}));
+vi.mock('@/features/ChatInput/InputEditor/ActionTag/skillDragData', () => ({
+ startSkillDrag: () => undefined,
+}));
+
vi.mock('@/services/agentDocument', () => ({
agentDocumentSWRKeys: {
documents: (agentId: string) => ['agent-documents', agentId],
@@ -393,7 +413,7 @@ describe('AgentDocumentsGroup', () => {
expect(openDocument).not.toHaveBeenCalled();
});
- it('shows the skills empty state when no bundles are present', () => {
+ it('falls back to a single empty placeholder when every skill source is empty', () => {
useClientDataSWR.mockReturnValue({
data: [fileDocRow, webDocRow],
error: undefined,
@@ -403,6 +423,9 @@ describe('AgentDocumentsGroup', () => {
render();
+ // No agent bundles, no working dir (no Project section), no user-installed
+ // skills → renderSkills collapses to the global "No skills found"
+ // placeholder rather than rendering an empty section per source.
expect(screen.getByText('No skills found')).toBeInTheDocument();
expect(screen.queryByTestId('skills-list')).not.toBeInTheDocument();
});
diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx
index 8d6db4d1bb..afb35c0101 100644
--- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx
+++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx
@@ -15,7 +15,12 @@ import { useMatch, useNavigate } from 'react-router-dom';
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
import { DocumentExplorerTree } from '@/features/AgentDocumentsExplorer';
import { startSkillDrag } from '@/features/ChatInput/InputEditor/ActionTag/skillDragData';
-import { type SkillListItem, SkillSection, SkillsList } from '@/features/SkillsList';
+import {
+ type SkillListItem,
+ SkillSection,
+ SkillsList,
+ useProjectSkills,
+} from '@/features/SkillsList';
import { useClientDataSWR } from '@/libs/swr';
import { agentDocumentService, agentDocumentSWRKeys } from '@/services/agentDocument';
import { useAgentStore } from '@/store/agent';
@@ -24,6 +29,7 @@ import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
import ProjectLevelSkills from './ProjectLevelSkills';
+import UserLevelSkills, { useUserSkills } from './UserLevelSkills';
const PAGE_ROUTE_PATTERN = '/agent/:aid/:topicId/page/:docId?';
@@ -272,6 +278,14 @@ const AgentDocumentsGroup = memo(({ style, workingDire
const showProjectSkills = isLocalEnabled && !!workingDirectory;
+ // Mirror what each child component reads so the parent can decide the
+ // section layout (flat when a single source has items, sectioned otherwise).
+ // Both hooks are SWR-deduped against their respective child fetches.
+ const userSkillItems = useUserSkills();
+ const { items: projectSkillItems } = useProjectSkills(
+ showProjectSkills ? workingDirectory : undefined,
+ );
+
const {
data = [],
error,
@@ -358,33 +372,45 @@ const AgentDocumentsGroup = memo(({ style, workingDire
);
const renderSkills = () => {
- // No project section (not local mode / no working dir): show the agent
- // skills flat, without the redundant "Agent skills" group header.
- if (!showProjectSkills) {
- if (skillItems.length === 0) {
- return (
-
-
-
- );
- }
- return renderAgentSkillsList();
+ // Sections render in fixed order — agent → project → user — and each one
+ // hides itself when it has nothing to show. When exactly one source has
+ // items we drop the group header and render the list flat (no redundant
+ // "User skills 1" label above a single row). When everything is empty we
+ // fall back to a single placeholder.
+ const hasAgent = skillItems.length > 0;
+ const hasProject = showProjectSkills && projectSkillItems.length > 0;
+ const hasUser = userSkillItems.length > 0;
+ const activeCount = (hasAgent ? 1 : 0) + (hasProject ? 1 : 0) + (hasUser ? 1 : 0);
+
+ if (activeCount === 0) {
+ return (
+
+
+
+ );
}
- // Both sections coexist — label each so the source is clear.
+ const flat = activeCount === 1;
+
return (
-
-
- {renderAgentSkillsList()}
-
-
+
+ {hasAgent &&
+ (flat ? (
+ renderAgentSkillsList()
+ ) : (
+
+ {renderAgentSkillsList()}
+
+ ))}
+ {hasProject && (
+
+ )}
+ {hasUser && }
);
};
diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/ProjectLevelSkills.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/ProjectLevelSkills.tsx
index 23e5f685bf..aac6672638 100644
--- a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/ProjectLevelSkills.tsx
+++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/ProjectLevelSkills.tsx
@@ -5,37 +5,47 @@ import { startSkillDrag } from '@/features/ChatInput/InputEditor/ActionTag/skill
import { SkillSection, SkillsList, useProjectSkills } from '@/features/SkillsList';
interface ProjectLevelSkillsProps {
+ /**
+ * Skip the `SkillSection` wrapper (no header row). Set when the parent has
+ * collapsed to a single visible source and wants the list rendered flat.
+ */
+ hideHeader?: boolean;
workingDirectory: string;
}
-const ProjectLevelSkills = memo(({ workingDirectory }) => {
+const ProjectLevelSkills = memo(({ hideHeader, workingDirectory }) => {
const { t } = useTranslation('chat');
- const { isLoading, items, onOpenFile, onOpenSkill } = useProjectSkills(workingDirectory);
+ const { items, onOpenFile, onOpenSkill } = useProjectSkills(workingDirectory);
+
+ if (items.length === 0) return null;
+
+ const list = (
+ {
+ // Project skills are resolved by the underlying CLI agent itself, so
+ // we serialize them as a literal `/skill-name` (projectSkill chip).
+ startSkillDrag(event, {
+ category: 'projectSkill',
+ label: item.name,
+ type: item.name,
+ });
+ }}
+ />
+ );
+
+ if (hideHeader) return list;
return (
- {
- // Project skills are resolved by the underlying CLI agent itself, so
- // we serialize them as a literal `/skill-name` (projectSkill chip).
- startSkillDrag(event, {
- category: 'projectSkill',
- label: item.name,
- type: item.name,
- });
- }}
- />
+ {list}
);
});
diff --git a/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/UserLevelSkills.tsx b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/UserLevelSkills.tsx
new file mode 100644
index 0000000000..be5ea6e029
--- /dev/null
+++ b/src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/UserLevelSkills.tsx
@@ -0,0 +1,82 @@
+import isEqual from 'fast-deep-equal';
+import { memo, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { startSkillDrag } from '@/features/ChatInput/InputEditor/ActionTag/skillDragData';
+import { type SkillListItem, SkillSection, SkillsList } from '@/features/SkillsList';
+import { useToolStore } from '@/store/tool';
+import { agentSkillsSelectors } from '@/store/tool/selectors';
+
+/**
+ * Reads user-installed skills (entries in the `agent_skill` table — market
+ * imports plus user-created customs) into the `SkillsList` row shape. Builtin
+ * tools and LobeHub MCP servers are intentionally excluded — those belong in
+ * the Tools popover, not in the per-user skill inventory.
+ *
+ * Also triggers the underlying SWR fetch so the working sidebar surfaces the
+ * data even when the Tools popover hasn't been opened in this session. The key
+ * is deduplicated, so co-mounting with `useControls` doesn't double-fetch.
+ */
+export const useUserSkills = (): SkillListItem[] => {
+ useToolStore((s) => s.useFetchAgentSkills)(true);
+ const agentSkills = useToolStore(agentSkillsSelectors.getAgentSkills, isEqual);
+
+ return useMemo(
+ () =>
+ agentSkills.map((skill) => ({
+ description: skill.description ?? undefined,
+ // `identifier` is what the runtime resolves through the skill registry,
+ // and is unique per skill — reuse it as both the React key and the
+ // drag payload's `type`.
+ id: skill.identifier,
+ name: skill.name,
+ })),
+ [agentSkills],
+ );
+};
+
+interface UserLevelSkillsProps {
+ /**
+ * Skip the `SkillSection` wrapper (no header row). Set when the parent has
+ * collapsed to a single visible source and wants the list rendered flat,
+ * matching the agent-only layout this used to ship with.
+ */
+ hideHeader?: boolean;
+}
+
+const UserLevelSkills = memo(({ hideHeader }) => {
+ const { t } = useTranslation('chat');
+ const items = useUserSkills();
+
+ if (items.length === 0) return null;
+
+ const list = (
+ {
+ startSkillDrag(event, {
+ category: 'skill',
+ label: item.name,
+ type: item.id,
+ });
+ }}
+ />
+ );
+
+ if (hideHeader) return list;
+
+ return (
+
+ {list}
+
+ );
+});
+
+UserLevelSkills.displayName = 'UserLevelSkills';
+
+export default UserLevelSkills;
diff --git a/src/server/modules/AgentRuntime/RuntimeExecutors.ts b/src/server/modules/AgentRuntime/RuntimeExecutors.ts
index 49c2ed393a..41bfdf356b 100644
--- a/src/server/modules/AgentRuntime/RuntimeExecutors.ts
+++ b/src/server/modules/AgentRuntime/RuntimeExecutors.ts
@@ -1705,6 +1705,15 @@ export const createRuntimeExecutors = (
memoryToolPermission: agentConfig?.chatConfig?.memory?.toolPermission,
messageId: state.metadata?.sourceMessageId,
operationId,
+ projectSkills: (state.metadata?.operationSkillSet?.skills ?? [])
+ .filter(
+ (skill: { location?: string; source?: string }) =>
+ skill.source === 'project' && !!skill.location,
+ )
+ .map((skill: { location: string; name: string }) => ({
+ location: skill.location,
+ name: skill.name,
+ })),
scope: state.metadata?.scope,
serverDB: ctx.serverDB,
skipResultTruncation: true,
diff --git a/src/server/routers/lambda/aiAgent.ts b/src/server/routers/lambda/aiAgent.ts
index 732a81f2a7..1deaccd7c0 100644
--- a/src/server/routers/lambda/aiAgent.ts
+++ b/src/server/routers/lambda/aiAgent.ts
@@ -160,6 +160,20 @@ const ExecAgentSchema = z
fileIds: z.array(z.string()).optional(),
/** Parent message ID for regeneration/continue (skip user message creation, branch from this message) */
parentMessageId: z.string().optional(),
+ /**
+ * Project-level skills discovered on the device filesystem
+ * (`.agents/skills` / `.claude/skills`) by the client at request time.
+ * Surfaced in `` and loaded on demand via readFile.
+ */
+ projectSkills: z
+ .array(
+ z.object({
+ description: z.string().optional(),
+ name: z.string(),
+ path: z.string(),
+ }),
+ )
+ .optional(),
/** The user input/prompt */
prompt: z.string(),
/**
@@ -624,6 +638,7 @@ export const aiAgentRouter = router({
existingMessageIds = [],
fileIds,
parentMessageId,
+ projectSkills,
resumeApproval,
trigger,
userInterventionConfig,
@@ -641,6 +656,7 @@ export const aiAgentRouter = router({
existingMessageIds,
fileIds,
parentMessageId,
+ projectSkills,
prompt,
// When parentMessageId is provided, this is a regeneration/continue or a
// human-approval resume — either way, skip user message creation.
diff --git a/src/server/services/agentDocuments/index.test.ts b/src/server/services/agentDocuments/index.test.ts
index ad3f567897..c767ffb9f5 100644
--- a/src/server/services/agentDocuments/index.test.ts
+++ b/src/server/services/agentDocuments/index.test.ts
@@ -650,4 +650,191 @@ describe('AgentDocumentsService', () => {
expect(result).toEqual({ id: 'ad-1' });
});
});
+
+ describe('getAgentSkills', () => {
+ // Inject docs with the derive flags already set so we test the
+ // bundle → index-child → identifier mapping in isolation, not the
+ // model's deriveAgentDocumentFields projection.
+ const stubDocs = (docs: Array>): any[] =>
+ docs.map((doc) => ({
+ content: '',
+ description: null,
+ filename: '',
+ isSkillBundle: false,
+ isSkillIndex: false,
+ parentId: null,
+ title: null,
+ ...doc,
+ }));
+
+ it('returns an empty list when the agent has no skill bundles', async () => {
+ const service = new AgentDocumentsService(db, userId);
+ vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
+ stubDocs([
+ { documentId: 'doc-1', filename: 'note.md', isSkillBundle: false },
+ { documentId: 'doc-2', filename: 'web.md', isSkillBundle: false },
+ ]),
+ );
+
+ const result = await service.getAgentSkills('agent-1');
+
+ expect(service.getAgentDocuments).toHaveBeenCalledWith('agent-1');
+ expect(result).toEqual([]);
+ });
+
+ it('prefixes the identifier with `agent-skills:` and pulls content from the SKILL.md index child', async () => {
+ const service = new AgentDocumentsService(db, userId);
+ vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
+ stubDocs([
+ {
+ content: '',
+ description: 'Triage workflow',
+ documentId: 'bundle-1',
+ filename: 'bug-triage',
+ isSkillBundle: true,
+ title: 'Bug Triage',
+ },
+ {
+ content: '# Bug triage\n\nbody',
+ documentId: 'index-1',
+ filename: 'SKILL.md',
+ isSkillIndex: true,
+ parentId: 'bundle-1',
+ },
+ // Sibling non-index child — must be ignored.
+ {
+ content: 'reference',
+ documentId: 'asset-1',
+ filename: 'reference.md',
+ parentId: 'bundle-1',
+ },
+ ]),
+ );
+
+ const result = await service.getAgentSkills('agent-1');
+
+ expect(result).toEqual([
+ {
+ content: '# Bug triage\n\nbody',
+ description: 'Triage workflow',
+ filename: 'bug-triage',
+ identifier: 'agent-skills:bug-triage',
+ name: 'agent-skills:bug-triage',
+ title: 'Bug Triage',
+ },
+ ]);
+ });
+
+ it('falls back to the bundle row content when the index child is missing', async () => {
+ const service = new AgentDocumentsService(db, userId);
+ vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
+ stubDocs([
+ {
+ content: 'orphan body',
+ description: null,
+ documentId: 'orphan-1',
+ filename: 'orphan-skill',
+ isSkillBundle: true,
+ title: 'Orphan',
+ },
+ ]),
+ );
+
+ const result = await service.getAgentSkills('agent-1');
+
+ expect(result).toEqual([
+ {
+ content: 'orphan body',
+ description: '',
+ filename: 'orphan-skill',
+ identifier: 'agent-skills:orphan-skill',
+ name: 'agent-skills:orphan-skill',
+ title: 'Orphan',
+ },
+ ]);
+ });
+
+ it('emits empty content for a bundle with no index child and no body', async () => {
+ const service = new AgentDocumentsService(db, userId);
+ vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
+ stubDocs([
+ {
+ content: '',
+ documentId: 'empty-1',
+ filename: 'empty',
+ isSkillBundle: true,
+ title: 'Empty',
+ },
+ ]),
+ );
+
+ const [skill] = await service.getAgentSkills('agent-1');
+
+ expect(skill.content).toBe('');
+ expect(skill.identifier).toBe('agent-skills:empty');
+ });
+
+ it('returns one entry per skill bundle and ignores non-bundle docs', async () => {
+ const service = new AgentDocumentsService(db, userId);
+ vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
+ stubDocs([
+ {
+ documentId: 'b-1',
+ filename: 'one',
+ isSkillBundle: true,
+ title: 'One',
+ },
+ {
+ content: 'one body',
+ documentId: 'b-1-idx',
+ isSkillIndex: true,
+ parentId: 'b-1',
+ },
+ {
+ documentId: 'b-2',
+ filename: 'two',
+ isSkillBundle: true,
+ title: 'Two',
+ },
+ {
+ content: 'two body',
+ documentId: 'b-2-idx',
+ isSkillIndex: true,
+ parentId: 'b-2',
+ },
+ // Unrelated regular doc.
+ { documentId: 'note', filename: 'note.md' },
+ ]),
+ );
+
+ const result = await service.getAgentSkills('agent-1');
+
+ expect(result.map((s) => s.identifier)).toEqual(['agent-skills:one', 'agent-skills:two']);
+ expect(result.map((s) => s.content)).toEqual(['one body', 'two body']);
+ });
+
+ it('matches index children strictly by parentId — does not leak across bundles', async () => {
+ const service = new AgentDocumentsService(db, userId);
+ vi.spyOn(service, 'getAgentDocuments').mockResolvedValue(
+ stubDocs([
+ { documentId: 'b-1', filename: 'first', isSkillBundle: true },
+ { documentId: 'b-2', filename: 'second', isSkillBundle: true },
+ // Only b-2 has an index child; b-1 must fall back to its own (empty)
+ // content rather than borrow b-2's content.
+ {
+ content: 'second body',
+ documentId: 'b-2-idx',
+ isSkillIndex: true,
+ parentId: 'b-2',
+ },
+ ]),
+ );
+
+ const result = await service.getAgentSkills('agent-1');
+
+ expect(result).toHaveLength(2);
+ expect(result.find((s) => s.filename === 'first')?.content).toBe('');
+ expect(result.find((s) => s.filename === 'second')?.content).toBe('second body');
+ });
+ });
});
diff --git a/src/server/services/aiAgent/index.ts b/src/server/services/aiAgent/index.ts
index 185826e9ae..ae04aa0ebb 100644
--- a/src/server/services/aiAgent/index.ts
+++ b/src/server/services/aiAgent/index.ts
@@ -2069,9 +2069,39 @@ export class AiAgentService {
name: skill.name,
}));
+ // Project skills are filesystem SKILL.md discovered on the device. They
+ // are only meaningful when a device is active (readFile resolves against
+ // it). Only `location` (absolute SKILL.md path) flows through — the
+ // skill's directory tree is enumerated lazily at activation time via
+ // `local-system.listFiles` over the device gateway, keeping the op-param
+ // payload small.
+ const projectMetas =
+ activeDeviceId && params.projectSkills?.length
+ ? params.projectSkills.map((s) => ({
+ description: s.description ?? '',
+ identifier: `project:${s.name}`,
+ location: s.path,
+ name: s.name,
+ source: 'project' as const,
+ }))
+ : [];
+
+ // Precedence on name collision: project > db > agent-skills > builtin.
+ // Agent-skills carry the `agent-skills:` prefix in their `name`, so they
+ // can only collide with each other — but we still dedupe by name to keep
+ // a single shape for the SkillEngine input.
+ const seenNames = new Set();
+ const skills = [...projectMetas, ...dbMetas, ...agentSkillMetas, ...builtinMetas].filter(
+ (skill) => {
+ if (seenNames.has(skill.name)) return false;
+ seenNames.add(skill.name);
+ return true;
+ },
+ );
+
const skillEngine = new SkillEngine({
enableChecker: (skill) => shouldEnableBuiltinSkill(skill.identifier),
- skills: [...builtinMetas, ...dbMetas, ...agentSkillMetas],
+ skills,
});
operationSkillSet = skillEngine.generate(agentPlugins ?? []);
} catch (error) {
diff --git a/src/server/services/toolExecution/serverRuntimes/skills.ts b/src/server/services/toolExecution/serverRuntimes/skills.ts
index 434fcabf9c..a1e7aac225 100644
--- a/src/server/services/toolExecution/serverRuntimes/skills.ts
+++ b/src/server/services/toolExecution/serverRuntimes/skills.ts
@@ -1,6 +1,10 @@
import { builtinSkills } from '@lobechat/builtin-skills';
+import { LocalSystemApiName, LocalSystemIdentifier } from '@lobechat/builtin-tool-local-system';
+// Note: only `readFile` is wired through deviceProxy. Directory enumeration is
+// left to the model via `local-system.listFiles` so we don't double-fetch.
import { type CommandResult, SkillsIdentifier } from '@lobechat/builtin-tool-skills';
import {
+ type DeviceFileAccess,
type ExportFileResult,
type SkillRuntimeService,
SkillsExecutionRuntime,
@@ -21,6 +25,7 @@ import { MarketService } from '@/server/services/market';
import { SkillResourceService } from '@/server/services/skill/resource';
import { preprocessLhCommand } from '@/server/services/toolExecution/preprocessLhCommand';
+import { deviceProxy } from '../deviceProxy';
import { type ServerRuntimeRegistration } from './types';
const log = debug('lobe-server:skills-runtime');
@@ -350,7 +355,9 @@ export const skillsRuntime: ServerRuntimeRegistration = {
// the identifier prefix and the bundle → index-child content resolution
// (also used by `aiAgent/index.ts` when building ``).
// `source: 'builtin'` is the type-system carrier shape required by
- // `BuiltinSkill`; the runtime never reads `source`.
+ // `BuiltinSkill`; the runtime re-tags `source: 'agent'` in the activateSkill
+ // result based on the identifier prefix so the inspector can show
+ // "Activate Agent Skill" + the friendly `title`.
const agentSkillBuiltins: BuiltinSkill[] = context.agentId
? await new AgentDocumentsService(context.serverDB, context.userId)
.getAgentSkills(context.agentId)
@@ -361,6 +368,7 @@ export const skillsRuntime: ServerRuntimeRegistration = {
identifier: skill.identifier,
name: skill.name,
source: 'builtin' as const,
+ ...(skill.title && { title: skill.title }),
})),
)
.catch((error) => {
@@ -369,8 +377,70 @@ export const skillsRuntime: ServerRuntimeRegistration = {
})
: [];
+ // Project skills live on the device filesystem. Read them through the
+ // device gateway by reusing the local-system tools — no special
+ // file-read primitive, just the existing capabilities over deviceProxy.
+ // - `readFile` loads SKILL.md and validated reference files.
+ // - `globFiles` enumerates the skill directory so `readReference` can
+ // reject paths the model guessed (e.g. `.env`) instead of trusting
+ // the raw string. The discovery payload no longer carries the file
+ // tree (see commit 8e8f3aed14), so we enumerate live at read time.
+ const { activeDeviceId, projectSkills } = context;
+ let deviceFileAccess: DeviceFileAccess | undefined;
+ if (activeDeviceId && context.userId) {
+ const userId = context.userId;
+ deviceFileAccess = {
+ listFiles: async (dir: string) => {
+ const result = await deviceProxy.executeToolCall(
+ { deviceId: activeDeviceId, userId },
+ {
+ apiName: LocalSystemApiName.globFiles,
+ // `**/*` matches every regular file recursively under `dir`.
+ // The device-side enumerator already skips hidden files; the
+ // runtime re-checks segments as defense in depth.
+ arguments: JSON.stringify({ pattern: '**/*', scope: dir }),
+ identifier: LocalSystemIdentifier,
+ },
+ );
+ if (!result.success) {
+ throw new Error(result.error || result.content || `globFiles failed: ${dir}`);
+ }
+ let payload: { files?: unknown };
+ try {
+ payload = JSON.parse(result.content) as { files?: unknown };
+ } catch {
+ throw new Error(`globFiles returned a non-JSON payload for ${dir}`);
+ }
+ if (!Array.isArray(payload.files)) return [];
+ // Files come back as paths relative to `scope` (POSIX). Strip any
+ // absolute path the engine may have emitted so the runtime can
+ // compare against normalized user-supplied relative paths.
+ return payload.files
+ .filter((f): f is string => typeof f === 'string')
+ .map((f) => (f.startsWith(dir) ? f.slice(dir.length).replace(/^[/\\]+/, '') : f));
+ },
+ readFile: async (filePath: string) => {
+ const result = await deviceProxy.executeToolCall(
+ { deviceId: activeDeviceId, userId },
+ {
+ apiName: LocalSystemApiName.readFile,
+ // Read the whole file; SKILL.md and references are small.
+ arguments: JSON.stringify({ loc: [0, 5000], path: filePath }),
+ identifier: LocalSystemIdentifier,
+ },
+ );
+ if (!result.success) {
+ throw new Error(result.error || result.content || `readFile failed: ${filePath}`);
+ }
+ return result.content;
+ },
+ };
+ }
+
return new SkillsExecutionRuntime({
builtinSkills: [...filterBuiltinSkills(builtinSkills), ...agentSkillBuiltins],
+ deviceFileAccess,
+ projectSkills,
service,
});
},
diff --git a/src/server/services/toolExecution/types.ts b/src/server/services/toolExecution/types.ts
index 339a2b4011..1a96824a5d 100644
--- a/src/server/services/toolExecution/types.ts
+++ b/src/server/services/toolExecution/types.ts
@@ -32,6 +32,12 @@ export interface ToolExecutionContext {
messageId?: string;
/** Agent runtime operation ID for structured tool outcome identity. */
operationId?: string;
+ /**
+ * Project-level skills (name + absolute SKILL.md path) discovered on the
+ * device filesystem. Used by the Skills runtime to load them on demand via
+ * the device gateway. Derived from the operation's skill set.
+ */
+ projectSkills?: { location: string; name: string }[];
/** Conversation scope captured when the operation was created */
scope?: string | null;
/** Server database for LobeHub Skills execution */
diff --git a/src/services/aiAgent.ts b/src/services/aiAgent.ts
index 1b3da706b1..2a5ac3f809 100644
--- a/src/services/aiAgent.ts
+++ b/src/services/aiAgent.ts
@@ -40,6 +40,12 @@ export interface ExecAgentTaskParams {
fileIds?: string[];
/** Parent message ID for regeneration/continue (skip user message creation, branch from this message) */
parentMessageId?: string;
+ /**
+ * Project-level skills discovered on the device filesystem
+ * (`.agents/skills` / `.claude/skills`). Surfaced in ``
+ * and loaded on demand via the readFile tool.
+ */
+ projectSkills?: { description?: string; name: string; path: string }[];
prompt: string;
/** Resume a previous op paused on `human_approve_required` instead of starting from a fresh user prompt. */
resumeApproval?: ResumeApprovalParam;
diff --git a/src/store/chat/slices/aiChat/actions/gateway.ts b/src/store/chat/slices/aiChat/actions/gateway.ts
index a7f2ca3b3e..e2bd0e38fa 100644
--- a/src/store/chat/slices/aiChat/actions/gateway.ts
+++ b/src/store/chat/slices/aiChat/actions/gateway.ts
@@ -8,15 +8,51 @@ import type { ConversationContext, ExecAgentResult, MessageMetadata } from '@lob
import { isDesktop } from '@/const/version';
import { aiAgentService, type ResumeApprovalParam } from '@/services/aiAgent';
+import { localFileService } from '@/services/electron/localFileService';
import { messageService } from '@/services/message';
import { topicService } from '@/services/topic';
+import { getAgentStoreState } from '@/store/agent';
+import { agentSelectors } from '@/store/agent/selectors';
import { consumePendingTopicRepos, getPendingTopicRepos } from '@/store/chat/pendingTopicRepos';
+import { topicSelectors } from '@/store/chat/selectors';
import type { ChatStore } from '@/store/chat/store';
import type { StoreSetter } from '@/store/types';
import { useUserStore } from '@/store/user';
import { createGatewayEventHandler } from './gatewayEventHandler';
+/**
+ * Scan the active working directory for project-level skills
+ * (`.agents/skills` / `.claude/skills`) so the server can surface them in
+ * ``. Desktop-only and best-effort: a failed scan must not
+ * block the send.
+ */
+const resolveProjectSkills = async (
+ get: () => ChatStore,
+): Promise<{ description?: string; name: string; path: string }[] | undefined> => {
+ if (!isDesktop) return undefined;
+
+ const topicWorkingDirectory = topicSelectors.currentTopicWorkingDirectory(get());
+ const agentWorkingDirectory = agentSelectors.currentAgentWorkingDirectory(getAgentStoreState());
+ const workingDirectory = topicWorkingDirectory ?? agentWorkingDirectory;
+ if (!workingDirectory) return undefined;
+
+ try {
+ const { skills } = await localFileService.listProjectSkills({ scope: workingDirectory });
+ if (skills.length === 0) return undefined;
+ // The directory tree is enumerated lazily at activation time by the Skills
+ // runtime (via the local-system `listFiles` tool), so we drop `files` here
+ // — keeps the op-param payload small.
+ return skills.map((skill) => ({
+ description: skill.description,
+ name: skill.name,
+ path: skill.path,
+ }));
+ } catch {
+ return undefined;
+ }
+};
+
type Setter = StoreSetter;
// ─── Types ───
@@ -317,6 +353,8 @@ export class GatewayActionImpl {
? this.#get().getOperationAbortSignal(parentOperationId)
: undefined;
+ const projectSkills = await resolveProjectSkills(this.#get);
+
const result = await aiAgentService.execAgentTask(
{
agentId: context.agentId,
@@ -336,6 +374,7 @@ export class GatewayActionImpl {
clientRuntime: isDesktop ? 'desktop' : 'web',
fileIds,
parentMessageId,
+ projectSkills,
prompt: message,
resumeApproval,
trigger: metadata?.trigger,