mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 20:30:52 +08:00
fix(skills): support agent-authored skills via skills tool init/register (#14184)
### What this PR does
Before this PR:
- Inside agent chat, the `skill-creator` skill could not actually
persist a new skill. The agent would write `SKILL.md` files to disk, but
the global skill DB row and `.claude/skills/` symlink were never
created, so authored skills silently failed to take effect.
- `SkillInstaller.install` was destructive when source and destination
resolved to the same path: the backup-and-rename step moved the source
out from under the subsequent copy, leaving no way to install from an
in-place source.
- Agents in the sidebar showed an unexplained purple `SOUL` badge next
to their name when Soul Mode was on, crowding out the agent name and
using internal jargon users could not understand.
After this PR:
- The claw MCP `skills` tool gains two new actions:
- `init name=<name>` — creates / resolves the absolute target directory
under the global Skills storage root and returns it to the agent.
- `register name=<name>` — registers the in-place directory through the
existing `installFromDirectory` pipeline and toggles the skill on for
the current session.
- `installSkill` (marketplace install) now also auto-toggles the skill
on, so an agent-driven install from chat is immediately usable without a
UI roundtrip.
- `SkillInstaller.install` short-circuits when src and dest resolve to
the same path.
- `SkillService.installSkillDir` detects in-place input under the Skills
root and preserves the existing basename as the folder name, avoiding
sanitize drift between caller-chosen names and `parseSkillMetadata`'s
derived filename.
- New public `SkillService.getSkillDirectory(name)` helper exposes the
resolved path so callers don't duplicate sanitize logic.
- `resources/skills/skill-creator/SKILL.md` gains a "Cherry Studio
workflow" preamble that overrides the Claude Code / Claude.ai packaging
steps and instructs the agent to use init/register. The original
sections are kept as-is.
- The `SOUL` badge is removed from the agent sidebar item along with the
now-dead `SoulTag` styled component and unused `isSoulModeEnabled`
import in `AgentItem.tsx`. The Soul Mode toggle and underlying feature
are unchanged — the toggle still lives in agent settings.
- `claw.test.ts`'s `skillService` mock is extended with `toggle` /
`installFromDirectory` / `getSkillDirectory` so the new install / init /
register actions have a working stub (fixes the CI failure on the first
push of this branch).
Fixes #
### Why we need it and why it was done in this way
The skill-creator skill is meant to let users author custom skills
directly from agent chat, but with no integration into `SkillService`
the result was always a silent failure — a `SKILL.md` sitting in some
directory that no part of the system knew about. Users saw the agent
confidently report "skill created" while the global skill list remained
empty. This is the fix for that broken end-to-end flow.
The SOUL badge removal is bundled into this hotfix because it landed on
the same review cycle and is a strict UI cleanup — reported as P0 by
Kenny / 郑克 with the same root cause as the rest of this PR's framing:
Soul Mode internals leaking into the user-facing surface.
The following tradeoffs were made:
- Extended the existing `skills` MCP tool with two additive actions
instead of introducing a new tool, keeping the agent's tool surface
small.
- Reused `installFromDirectory` rather than adding a parallel "register
in place" code path. This required two small robustness fixes
(src===dest no-op + in-place folder name detection) but keeps a single
install entry point.
- The skill is auto-enabled on register (and on marketplace install); an
agent-authored or agent-installed skill that requires a manual UI toggle
would surprise users who just told the agent "create / install a skill
for X".
- Just delete the SOUL badge instead of trying to make it discoverable
(tooltip / icon-only / rename). The badge was not pulling its weight in
the list item, and the settings panel is the right place for users to
learn about Soul Mode.
The following alternatives were considered:
- A new `registerInPlace` method on `SkillService` bypassing
`installer.install` entirely. Rejected to avoid duplicating insert/link
logic.
- A staging directory under `app.getPath('temp')` copied into the Skills
root on register. Rejected because it forces an extra copy and breaks
live iteration: with in-place files, the `.claude/skills/<name>/`
symlink reflects edits immediately, so no second `register` is needed
for runtime effect.
- A file watcher / per-turn reconciliation that auto-discovers
`SKILL.md` files in the agent workspace. Rejected as too magical for a
first cut; can revisit later.
- For the badge: keep but rename / translate. Rejected — the underlying
problem is that "Soul Mode" itself is internal-facing terminology, so
renaming the badge would only move the comprehension issue.
Links to places where the discussion took place: N/A
### Breaking changes
None. The new tool actions are additive and existing `search` /
`install` / `remove` / `list` behavior is unchanged. The
`SkillInstaller.install` short-circuit only fires when src and dest
resolve to the exact same path, which previously raised — strictly more
permissive than before. The badge removal is UI-only and the Soul Mode
feature is untouched.
### Special notes for your reviewer
- Functionally most of this PR fixes a silent failure ("agent-authored
skills don't take effect"), but it also introduces new MCP tool actions,
so the underlying commits have a mix of `feat:` and `fix:` prefixes. The
PR title uses `fix:` since it lands on a hotfix branch.
- The skill-creator SKILL.md change only **adds** a "Cherry Studio
workflow" preamble at the top; nothing else is removed, so upstream
changes to the rest of the file still merge cleanly.
- The SOUL badge removal commit was originally on a separate branch / PR
(#14185, now closed) and is cherry-picked into this branch so it can
ship in the same review cycle.
- The CI fix commit (`test(skills): add toggle mock to claw test ...`)
is here because the first push hit `skillService.toggle is not a
function` — the `installSkill` change calls toggle but the existing mock
didn't expose it.
- Manual testing: end-to-end verified by the author — agent runs `skills
init` → writes SKILL.md and supporting files → calls `skills register` →
the new skill appears in the global skill list and is enabled for the
session. Sidebar verified to no longer show the SOUL badge after the
cherry-pick.
### Checklist
- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: Write code that humans can understand and Keep it simple
- [x] Refactor: Boy Scout Rule applied — `SkillInstaller.install` is now
safer for any in-place caller, and AgentItem dropped the now-dead
`SoulTag` component along with the unused import
- [x] Upgrade: No upgrade impact (additive, no schema migration)
- [x] Documentation: skill-creator SKILL.md preamble updated; no
external docs change required
- [x] Self-review: Reviewed via local diff before pushing
### Release note
```release-note
fix(skills): agents in Soul Mode can now author new skills end-to-end from agent chat. The `skills` tool gains `init` / `register` actions, marketplace install auto-enables, and the skill-creator skill persists to the global skill list. UI cleanup: the unexplained SOUL badge next to agent names in the sidebar is removed; Soul Mode itself is unchanged and still toggleable from agent settings.
fix(agents): the skills and memory MCP tools are now available to every agent (not just Soul-Mode agents), with cross-session FACT.md recall, auto-approved permissions, and `skills list` exposing on-disk paths so the agent can patch installed skills in place via Read/Edit. Skill management (search/install/list/remove/init/register) and persistent workspace memory work in regular chat agents the same way they did in Soul Mode.
```
---------
Signed-off-by: suyao <sy20010504@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,38 @@ name: skill-creator
|
||||
description: Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, edit, or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.
|
||||
---
|
||||
|
||||
## Cherry Studio workflow (READ FIRST — overrides packaging / install steps below)
|
||||
|
||||
You are running inside Cherry Studio. Skills live in a managed global registry,
|
||||
so you do **not** write files to `.claude/skills/` or to
|
||||
`~/Library/Application Support/.../Skills/` directly, and you should **ignore**
|
||||
any `package_skill.py` / `.skill` packaging steps mentioned later in this file
|
||||
(they apply to Claude Code / Claude.ai, not here).
|
||||
|
||||
**The flow for creating a new skill is exactly two tool calls:**
|
||||
|
||||
1. Call the `skills` tool with `action="init"` and `name="<skill-folder-name>"`.
|
||||
It returns an absolute directory path. Write `SKILL.md` and any supporting
|
||||
files (`scripts/`, `references/`, `assets/`) **directly into that directory**.
|
||||
2. When the skill is ready, call `skills` with `action="register"` and the same
|
||||
`name`. The skill is registered into the global skill list and enabled for
|
||||
the current session automatically. You can re-edit files in place and call
|
||||
`register` again at any time to refresh — the live symlink picks up file
|
||||
content changes immediately, so mid-iteration edits work without ceremony.
|
||||
|
||||
Use the same `<skill-folder-name>` for both `init` and `register` calls. The
|
||||
`name:` field inside your `SKILL.md` frontmatter becomes the display name and
|
||||
may differ from the folder name (e.g. `name: My Cool Skill` with folder
|
||||
`my-cool-skill`).
|
||||
|
||||
Eval / test workspaces (`<skill-name>-workspace/`, `iteration-*/`, etc.) from
|
||||
the evaluation loop described below should be created **outside** the skill
|
||||
directory — e.g. as a sibling under the user's workspace — so they don't end up
|
||||
bundled into the registered skill. The evaluation loop itself still applies;
|
||||
only the packaging and install mechanics change.
|
||||
|
||||
---
|
||||
|
||||
# Skill Creator
|
||||
|
||||
A skill for creating new skills and iteratively improving them.
|
||||
|
||||
@@ -6,39 +6,18 @@ const mockListTasks = vi.fn()
|
||||
const mockDeleteTask = vi.fn()
|
||||
const mockGetNotifyAdapters = vi.fn()
|
||||
const mockSendMessage = vi.fn()
|
||||
const mockSkillInstall = vi.fn()
|
||||
const mockSkillUninstallByFolderName = vi.fn()
|
||||
const mockSkillList = vi.fn()
|
||||
const mockNetFetch = vi.fn()
|
||||
const mockGetAgent = vi.fn()
|
||||
const mockUpdateAgent = vi.fn()
|
||||
const mockSyncChannel = vi.fn()
|
||||
const mockDisconnectChannel = vi.fn()
|
||||
const mockWaitForQrUrl = vi.fn()
|
||||
const mockQRCodeToDataURL = vi.fn()
|
||||
const mockMkdir = vi.fn()
|
||||
const mockWriteFile = vi.fn()
|
||||
const mockRename = vi.fn()
|
||||
const mockAppendFile = vi.fn()
|
||||
const mockReadFile = vi.fn()
|
||||
const mockReaddir = vi.fn()
|
||||
const mockStat = vi.fn()
|
||||
const mockListChannels = vi.fn()
|
||||
const mockCreateChannel = vi.fn()
|
||||
const mockGetChannel = vi.fn()
|
||||
const mockUpdateChannel = vi.fn()
|
||||
const mockDeleteChannel = vi.fn()
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
mkdir: (...args: unknown[]) => mockMkdir(...args),
|
||||
writeFile: (...args: unknown[]) => mockWriteFile(...args),
|
||||
rename: (...args: unknown[]) => mockRename(...args),
|
||||
appendFile: (...args: unknown[]) => mockAppendFile(...args),
|
||||
readFile: (...args: unknown[]) => mockReadFile(...args),
|
||||
readdir: (...args: unknown[]) => mockReaddir(...args),
|
||||
stat: (...args: unknown[]) => mockStat(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@main/services/agents/services/TaskService', () => ({
|
||||
taskService: {
|
||||
createTask: mockCreateTask,
|
||||
@@ -69,14 +48,6 @@ vi.mock('qrcode', () => ({
|
||||
default: { toDataURL: mockQRCodeToDataURL }
|
||||
}))
|
||||
|
||||
vi.mock('@main/services/agents/skills', () => ({
|
||||
skillService: {
|
||||
install: mockSkillInstall,
|
||||
uninstallByFolderName: mockSkillUninstallByFolderName,
|
||||
list: mockSkillList
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@main/services/agents/services/ChannelService', () => ({
|
||||
channelService: {
|
||||
listChannels: mockListChannels,
|
||||
@@ -93,11 +64,6 @@ vi.mock('@main/services/WindowService', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Import after mocks — electron is mocked globally in main.setup.ts
|
||||
// Override net.fetch with our local mock
|
||||
const electron = await import('electron')
|
||||
vi.mocked(electron.net.fetch).mockImplementation(mockNetFetch)
|
||||
|
||||
const { default: ClawServer } = await import('../claw')
|
||||
type ClawServerInstance = InstanceType<typeof ClawServer>
|
||||
|
||||
@@ -137,8 +103,8 @@ describe('ClawServer', () => {
|
||||
it('should list all tools', async () => {
|
||||
const server = createServer()
|
||||
const result = await listTools(server)
|
||||
expect(result.tools).toHaveLength(5)
|
||||
expect(result.tools.map((t: any) => t.name)).toEqual(['cron', 'notify', 'skills', 'memory', 'config'])
|
||||
expect(result.tools).toHaveLength(3)
|
||||
expect(result.tools.map((t: any) => t.name)).toEqual(['cron', 'notify', 'config'])
|
||||
})
|
||||
|
||||
describe('add action', () => {
|
||||
@@ -357,259 +323,6 @@ describe('ClawServer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('skills tool', () => {
|
||||
it('should search marketplace skills', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
skills: [
|
||||
{
|
||||
name: 'gh-create-pr',
|
||||
description: 'Create GitHub PRs',
|
||||
author: 'test-author',
|
||||
namespace: '@test-owner/test-repo',
|
||||
installs: 42,
|
||||
metadata: { repoOwner: 'test-owner', repoName: 'test-repo' }
|
||||
}
|
||||
],
|
||||
total: 1
|
||||
})
|
||||
}
|
||||
mockNetFetch.mockResolvedValue(mockResponse)
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'search', query: 'github pr' }, 'skills')
|
||||
|
||||
expect(mockNetFetch).toHaveBeenCalledWith(expect.stringContaining('/api/skills'), { method: 'GET' })
|
||||
expect(result.content[0].text).toContain('gh-create-pr')
|
||||
expect(result.content[0].text).toContain('test-owner/test-repo/gh-create-pr')
|
||||
})
|
||||
|
||||
it('should handle empty search results', async () => {
|
||||
mockNetFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ skills: [], total: 0 })
|
||||
})
|
||||
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'search', query: 'nonexistent' }, 'skills')
|
||||
|
||||
expect(result.content[0].text).toContain('No skills found')
|
||||
})
|
||||
|
||||
it('should error when query is missing for search', async () => {
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'search' }, 'skills')
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain("'query' is required")
|
||||
})
|
||||
|
||||
it('should install a marketplace skill', async () => {
|
||||
mockSkillInstall.mockResolvedValue({
|
||||
id: 'skill-1',
|
||||
name: 'gh-create-pr',
|
||||
description: 'Create PRs',
|
||||
folderName: 'gh-create-pr',
|
||||
isEnabled: false
|
||||
})
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'install', identifier: 'owner/repo/gh-create-pr' }, 'skills')
|
||||
|
||||
expect(mockSkillInstall).toHaveBeenCalledWith({
|
||||
installSource: 'claude-plugins:owner/repo/gh-create-pr'
|
||||
})
|
||||
expect(result.content[0].text).toContain('Skill installed')
|
||||
expect(result.content[0].text).toContain('gh-create-pr')
|
||||
})
|
||||
|
||||
it('should error when identifier is missing for install', async () => {
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'install' }, 'skills')
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain("'identifier' is required")
|
||||
})
|
||||
|
||||
it('should remove an installed skill', async () => {
|
||||
mockSkillUninstallByFolderName.mockResolvedValue(undefined)
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'remove', name: 'gh-create-pr' }, 'skills')
|
||||
|
||||
expect(mockSkillUninstallByFolderName).toHaveBeenCalledWith('gh-create-pr')
|
||||
expect(result.content[0].text).toContain('removed')
|
||||
})
|
||||
|
||||
it('should error when name is missing for remove', async () => {
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'remove' }, 'skills')
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain("'name' is required")
|
||||
})
|
||||
|
||||
it('should list installed skills', async () => {
|
||||
mockSkillList.mockResolvedValue([
|
||||
{ id: '1', name: 'gh-create-pr', description: 'Create PRs', folderName: 'gh-create-pr', isEnabled: true },
|
||||
{ id: '2', name: 'code-review', description: 'Review code', folderName: 'code-review', isEnabled: true }
|
||||
])
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'list' }, 'skills')
|
||||
|
||||
expect(mockSkillList).toHaveBeenCalled()
|
||||
expect(result.content[0].text).toContain('gh-create-pr')
|
||||
expect(result.content[0].text).toContain('code-review')
|
||||
})
|
||||
|
||||
it('should handle empty skills list', async () => {
|
||||
mockSkillList.mockResolvedValue([])
|
||||
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'list' }, 'skills')
|
||||
|
||||
expect(result.content[0].text).toBe('No skills installed.')
|
||||
})
|
||||
|
||||
it('should handle unknown skills action', async () => {
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'unknown' }, 'skills')
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain('Unknown action')
|
||||
})
|
||||
})
|
||||
|
||||
describe('memory tool', () => {
|
||||
const agentWithWorkspace = { accessible_paths: ['/workspace/test'] }
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetAgent.mockResolvedValue(agentWithWorkspace)
|
||||
mockMkdir.mockResolvedValue(undefined)
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
mockRename.mockResolvedValue(undefined)
|
||||
mockAppendFile.mockResolvedValue(undefined)
|
||||
// resolveFileCI: exact path always found (case-sensitive match)
|
||||
mockStat.mockResolvedValue({ mtimeMs: 1000 })
|
||||
})
|
||||
|
||||
it('should update FACT.md atomically', async () => {
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'update', content: '# Facts\n\nNew knowledge' }, 'memory')
|
||||
|
||||
expect(mockMkdir).toHaveBeenCalledWith('/workspace/test/memory', { recursive: true })
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FACT.md.'),
|
||||
'# Facts\n\nNew knowledge',
|
||||
'utf-8'
|
||||
)
|
||||
expect(mockRename).toHaveBeenCalled()
|
||||
expect(result.content[0].text).toBe('Memory updated.')
|
||||
})
|
||||
|
||||
it('should error when content is missing for update', async () => {
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'update' }, 'memory')
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain("'content' is required")
|
||||
})
|
||||
|
||||
it('should append journal entry with tags', async () => {
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(
|
||||
server,
|
||||
{ action: 'append', text: 'Deployed v2.0', tags: ['deploy', 'release'] },
|
||||
'memory'
|
||||
)
|
||||
|
||||
expect(mockAppendFile).toHaveBeenCalledWith(
|
||||
'/workspace/test/memory/JOURNAL.jsonl',
|
||||
expect.stringContaining('"text":"Deployed v2.0"'),
|
||||
'utf-8'
|
||||
)
|
||||
expect(result.content[0].text).toContain('Journal entry added')
|
||||
})
|
||||
|
||||
it('should error when text is missing for append', async () => {
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'append' }, 'memory')
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain("'text' is required")
|
||||
})
|
||||
|
||||
it('should search journal entries', async () => {
|
||||
const entries = [
|
||||
'{"ts":"2024-01-01T00:00:00Z","tags":["deploy"],"text":"Deployed v1.0"}',
|
||||
'{"ts":"2024-01-02T00:00:00Z","tags":["bugfix"],"text":"Fixed login bug"}',
|
||||
'{"ts":"2024-01-03T00:00:00Z","tags":["deploy"],"text":"Deployed v2.0"}'
|
||||
].join('\n')
|
||||
mockReadFile.mockResolvedValue(entries)
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'search', tag: 'deploy' }, 'memory')
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text)
|
||||
expect(parsed).toHaveLength(2)
|
||||
expect(parsed[0].text).toBe('Deployed v2.0') // reverse chronological
|
||||
})
|
||||
|
||||
it('should search journal with text query', async () => {
|
||||
const entries = [
|
||||
'{"ts":"2024-01-01T00:00:00Z","tags":[],"text":"Setup project"}',
|
||||
'{"ts":"2024-01-02T00:00:00Z","tags":[],"text":"Fixed login bug"}'
|
||||
].join('\n')
|
||||
mockReadFile.mockResolvedValue(entries)
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'search', query: 'login' }, 'memory')
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text)
|
||||
expect(parsed).toHaveLength(1)
|
||||
expect(parsed[0].text).toBe('Fixed login bug')
|
||||
})
|
||||
|
||||
it('should return message when journal has no matches', async () => {
|
||||
mockReadFile.mockResolvedValue('{"ts":"2024-01-01T00:00:00Z","tags":[],"text":"hello"}\n')
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'search', query: 'nonexistent' }, 'memory')
|
||||
|
||||
expect(result.content[0].text).toBe('No matching journal entries found.')
|
||||
})
|
||||
|
||||
it('should return message when journal does not exist', async () => {
|
||||
mockReadFile.mockRejectedValue(new Error('ENOENT'))
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'search' }, 'memory')
|
||||
|
||||
expect(result.content[0].text).toBe('No journal entries found.')
|
||||
})
|
||||
|
||||
it('should error when agent has no workspace', async () => {
|
||||
mockGetAgent.mockResolvedValue({ accessible_paths: [] })
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'update', content: 'test' }, 'memory')
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain('no workspace path')
|
||||
})
|
||||
|
||||
it('should handle unknown memory action', async () => {
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'unknown' }, 'memory')
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain('Unknown action')
|
||||
})
|
||||
})
|
||||
|
||||
describe('config tool', () => {
|
||||
const telegramChannel = {
|
||||
id: 'ch_1',
|
||||
|
||||
409
src/main/mcpServers/__tests__/skills.test.ts
Normal file
409
src/main/mcpServers/__tests__/skills.test.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mocks must be declared before importing SkillsServer
|
||||
const mockSkillInstall = vi.fn()
|
||||
const mockSkillUninstallByFolderName = vi.fn()
|
||||
const mockSkillList = vi.fn()
|
||||
const mockSkillToggle = vi.fn()
|
||||
const mockSkillInstallFromDirectory = vi.fn()
|
||||
const mockSkillGetSkillDirectory = vi.fn()
|
||||
const mockSkillGetByFolderName = vi.fn()
|
||||
const mockNetFetch = vi.fn()
|
||||
const mockMkdir = vi.fn()
|
||||
const mockReaddir = vi.fn()
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
mkdir: (...args: unknown[]) => mockMkdir(...args),
|
||||
readdir: (...args: unknown[]) => mockReaddir(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@main/services/agents/skills', () => ({
|
||||
skillService: {
|
||||
install: mockSkillInstall,
|
||||
uninstallByFolderName: mockSkillUninstallByFolderName,
|
||||
list: mockSkillList,
|
||||
toggle: mockSkillToggle,
|
||||
installFromDirectory: mockSkillInstallFromDirectory,
|
||||
getSkillDirectory: mockSkillGetSkillDirectory,
|
||||
getByFolderName: mockSkillGetByFolderName
|
||||
}
|
||||
}))
|
||||
|
||||
// Override net.fetch with our local mock — electron is mocked globally in main.setup.ts
|
||||
const electron = await import('electron')
|
||||
vi.mocked(electron.net.fetch).mockImplementation(mockNetFetch)
|
||||
|
||||
const { default: SkillsServer } = await import('../skills')
|
||||
type SkillsServerInstance = InstanceType<typeof SkillsServer>
|
||||
|
||||
function createServer(agentId = 'agent_test') {
|
||||
return new SkillsServer(agentId)
|
||||
}
|
||||
|
||||
async function callTool(server: SkillsServerInstance, args: Record<string, unknown>) {
|
||||
const handlers = (server.mcpServer.server as any)._requestHandlers
|
||||
const callToolHandler = handlers?.get('tools/call')
|
||||
if (!callToolHandler) {
|
||||
throw new Error('No tools/call handler registered')
|
||||
}
|
||||
return callToolHandler({ method: 'tools/call', params: { name: 'skills', arguments: args } }, {})
|
||||
}
|
||||
|
||||
async function listTools(server: SkillsServerInstance) {
|
||||
const handlers = (server.mcpServer.server as any)._requestHandlers
|
||||
const listHandler = handlers?.get('tools/list')
|
||||
if (!listHandler) {
|
||||
throw new Error('No tools/list handler registered')
|
||||
}
|
||||
return listHandler({ method: 'tools/list', params: {} }, {})
|
||||
}
|
||||
|
||||
describe('SkillsServer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSkillToggle.mockResolvedValue({ id: 'skill-1', isEnabled: true })
|
||||
})
|
||||
|
||||
it('should expose only the skills tool', async () => {
|
||||
const server = createServer()
|
||||
const result = await listTools(server)
|
||||
expect(result.tools).toHaveLength(1)
|
||||
expect(result.tools[0].name).toBe('skills')
|
||||
})
|
||||
|
||||
describe('search action', () => {
|
||||
it('should search marketplace skills', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
skills: [
|
||||
{
|
||||
name: 'gh-create-pr',
|
||||
description: 'Create GitHub PRs',
|
||||
author: 'test-author',
|
||||
namespace: '@test-owner/test-repo',
|
||||
installs: 42,
|
||||
metadata: { repoOwner: 'test-owner', repoName: 'test-repo' }
|
||||
}
|
||||
],
|
||||
total: 1
|
||||
})
|
||||
}
|
||||
mockNetFetch.mockResolvedValue(mockResponse)
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'search', query: 'github pr' })
|
||||
|
||||
expect(mockNetFetch).toHaveBeenCalledWith(expect.stringContaining('/api/skills'), { method: 'GET' })
|
||||
expect(result.content[0].text).toContain('gh-create-pr')
|
||||
expect(result.content[0].text).toContain('test-owner/test-repo/gh-create-pr')
|
||||
})
|
||||
|
||||
it('should handle empty search results', async () => {
|
||||
mockNetFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ skills: [], total: 0 })
|
||||
})
|
||||
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'search', query: 'nonexistent' })
|
||||
|
||||
expect(result.content[0].text).toContain('No skills found')
|
||||
})
|
||||
|
||||
it('should error when query is missing', async () => {
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'search' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain("'query' is required")
|
||||
})
|
||||
})
|
||||
|
||||
describe('install action', () => {
|
||||
it('should install and auto-enable a marketplace skill', async () => {
|
||||
mockSkillInstall.mockResolvedValue({
|
||||
id: 'skill-1',
|
||||
name: 'gh-create-pr',
|
||||
description: 'Create PRs',
|
||||
folderName: 'gh-create-pr',
|
||||
isEnabled: false
|
||||
})
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'install', identifier: 'owner/repo/gh-create-pr' })
|
||||
|
||||
expect(mockSkillInstall).toHaveBeenCalledWith({
|
||||
installSource: 'claude-plugins:owner/repo/gh-create-pr'
|
||||
})
|
||||
expect(mockSkillToggle).toHaveBeenCalledWith({ skillId: 'skill-1', isEnabled: true })
|
||||
expect(result.content[0].text).toContain('Skill installed and enabled')
|
||||
expect(result.content[0].text).toContain('gh-create-pr')
|
||||
})
|
||||
|
||||
it('should warn when toggle fails after install', async () => {
|
||||
mockSkillInstall.mockResolvedValue({
|
||||
id: 'skill-1',
|
||||
name: 'gh-create-pr',
|
||||
description: 'Create PRs',
|
||||
folderName: 'gh-create-pr',
|
||||
isEnabled: false
|
||||
})
|
||||
mockSkillToggle.mockResolvedValue(null)
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'install', identifier: 'owner/repo/gh-create-pr' })
|
||||
|
||||
expect(result.content[0].text).toContain('warning: failed to enable')
|
||||
expect(result.content[0].text).toContain('Enabled: false')
|
||||
})
|
||||
|
||||
it('should error when identifier is missing', async () => {
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'install' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain("'identifier' is required")
|
||||
})
|
||||
})
|
||||
|
||||
describe('remove action', () => {
|
||||
it('should remove an installed skill', async () => {
|
||||
mockSkillUninstallByFolderName.mockResolvedValue(undefined)
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'remove', name: 'gh-create-pr' })
|
||||
|
||||
expect(mockSkillUninstallByFolderName).toHaveBeenCalledWith('gh-create-pr')
|
||||
expect(result.content[0].text).toContain('removed')
|
||||
})
|
||||
|
||||
it('should error when name is missing', async () => {
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'remove' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain("'name' is required")
|
||||
})
|
||||
})
|
||||
|
||||
describe('list action', () => {
|
||||
it('should list installed skills with absolute on-disk paths', async () => {
|
||||
mockSkillList.mockResolvedValue([
|
||||
{ id: '1', name: 'gh-create-pr', description: 'Create PRs', folderName: 'gh-create-pr', isEnabled: true },
|
||||
{ id: '2', name: 'code-review', description: 'Review code', folderName: 'code-review', isEnabled: true }
|
||||
])
|
||||
mockSkillGetSkillDirectory.mockImplementation((folder: string) => `/global-skills/${folder}`)
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'list' })
|
||||
|
||||
expect(mockSkillList).toHaveBeenCalled()
|
||||
const parsed = JSON.parse(result.content[0].text)
|
||||
expect(parsed).toHaveLength(2)
|
||||
// Each entry must include the absolute path so the model can patch the
|
||||
// skill in place via Read / Edit on the symlinked files.
|
||||
expect(parsed[0]).toMatchObject({
|
||||
name: 'gh-create-pr',
|
||||
folder: 'gh-create-pr',
|
||||
path: '/global-skills/gh-create-pr',
|
||||
enabled: true
|
||||
})
|
||||
expect(parsed[1]).toMatchObject({
|
||||
name: 'code-review',
|
||||
folder: 'code-review',
|
||||
path: '/global-skills/code-review',
|
||||
enabled: true
|
||||
})
|
||||
expect(mockSkillGetSkillDirectory).toHaveBeenCalledWith('gh-create-pr')
|
||||
expect(mockSkillGetSkillDirectory).toHaveBeenCalledWith('code-review')
|
||||
})
|
||||
|
||||
it('should handle empty list', async () => {
|
||||
mockSkillList.mockResolvedValue([])
|
||||
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'list' })
|
||||
|
||||
expect(result.content[0].text).toBe('No skills installed.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('init action', () => {
|
||||
it('should create the skill directory and return its path', async () => {
|
||||
mockSkillGetSkillDirectory.mockReturnValue('/global-skills/my-skill')
|
||||
mockSkillGetByFolderName.mockResolvedValue(null)
|
||||
mockReaddir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
||||
mockMkdir.mockResolvedValue(undefined)
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'init', name: 'my-skill' })
|
||||
|
||||
expect(mockSkillGetSkillDirectory).toHaveBeenCalledWith('my-skill')
|
||||
expect(mockMkdir).toHaveBeenCalledWith('/global-skills/my-skill', { recursive: true })
|
||||
expect(result.content[0].text).toContain('/global-skills/my-skill')
|
||||
expect(result.content[0].text).toContain('register')
|
||||
})
|
||||
|
||||
it('should reject when a skill with the same folder name already exists in DB', async () => {
|
||||
mockSkillGetSkillDirectory.mockReturnValue('/global-skills/my-skill')
|
||||
mockSkillGetByFolderName.mockResolvedValue({
|
||||
id: 'existing-id',
|
||||
name: 'My Existing Skill',
|
||||
folderName: 'my-skill'
|
||||
})
|
||||
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'init', name: 'my-skill' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain('already exists')
|
||||
expect(result.content[0].text).toContain('My Existing Skill')
|
||||
expect(result.content[0].text).toContain('action="remove"')
|
||||
expect(mockMkdir).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject when directory exists and is non-empty but not tracked in DB', async () => {
|
||||
mockSkillGetSkillDirectory.mockReturnValue('/global-skills/my-skill')
|
||||
mockSkillGetByFolderName.mockResolvedValue(null)
|
||||
mockReaddir.mockResolvedValue(['SKILL.md', 'scripts'])
|
||||
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'init', name: 'my-skill' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain('already exists and is non-empty')
|
||||
expect(mockMkdir).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow init when directory exists but is empty', async () => {
|
||||
mockSkillGetSkillDirectory.mockReturnValue('/global-skills/my-skill')
|
||||
mockSkillGetByFolderName.mockResolvedValue(null)
|
||||
mockReaddir.mockResolvedValue([])
|
||||
mockMkdir.mockResolvedValue(undefined)
|
||||
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'init', name: 'my-skill' })
|
||||
|
||||
expect(result.content[0].text).toContain('Skill directory ready at:')
|
||||
expect(mockMkdir).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject when readdir fails with non-ENOENT error (e.g. EACCES)', async () => {
|
||||
mockSkillGetSkillDirectory.mockReturnValue('/global-skills/my-skill')
|
||||
mockSkillGetByFolderName.mockResolvedValue(null)
|
||||
mockReaddir.mockRejectedValue(Object.assign(new Error('Permission denied'), { code: 'EACCES' }))
|
||||
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'init', name: 'my-skill' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain('Cannot read skill directory')
|
||||
expect(mockMkdir).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should error when name is missing', async () => {
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'init' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain("'name' is required")
|
||||
})
|
||||
})
|
||||
|
||||
describe('register action', () => {
|
||||
it('should register an in-place skill and enable it', async () => {
|
||||
mockSkillGetSkillDirectory.mockReturnValue('/global-skills/my-skill')
|
||||
mockReaddir.mockResolvedValue(['SKILL.md', 'scripts'])
|
||||
mockSkillInstallFromDirectory.mockResolvedValue({
|
||||
id: 'skill-2',
|
||||
name: 'My Skill',
|
||||
description: 'Cool skill',
|
||||
folderName: 'my-skill',
|
||||
isEnabled: false
|
||||
})
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'register', name: 'my-skill' })
|
||||
|
||||
expect(mockSkillInstallFromDirectory).toHaveBeenCalledWith({ directoryPath: '/global-skills/my-skill' })
|
||||
expect(mockSkillToggle).toHaveBeenCalledWith({ skillId: 'skill-2', isEnabled: true })
|
||||
expect(result.content[0].text).toContain('My Skill')
|
||||
expect(result.content[0].text).toContain('registered and enabled')
|
||||
})
|
||||
|
||||
it('should error when SKILL.md is missing from directory', async () => {
|
||||
mockSkillGetSkillDirectory.mockReturnValue('/global-skills/my-skill')
|
||||
mockReaddir.mockResolvedValue(['scripts', 'README.md'])
|
||||
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'register', name: 'my-skill' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain('No SKILL.md found')
|
||||
expect(mockSkillInstallFromDirectory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should error when directory does not exist', async () => {
|
||||
mockSkillGetSkillDirectory.mockReturnValue('/global-skills/my-skill')
|
||||
mockReaddir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
||||
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'register', name: 'my-skill' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain('does not exist')
|
||||
expect(result.content[0].text).toContain('Did you call action="init" first')
|
||||
expect(mockSkillInstallFromDirectory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should error with InternalError when readdir fails with EACCES', async () => {
|
||||
mockSkillGetSkillDirectory.mockReturnValue('/global-skills/my-skill')
|
||||
mockReaddir.mockRejectedValue(Object.assign(new Error('Permission denied'), { code: 'EACCES' }))
|
||||
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'register', name: 'my-skill' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain('Cannot read skill directory')
|
||||
expect(result.content[0].text).not.toContain('Did you call action="init" first')
|
||||
expect(mockSkillInstallFromDirectory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should warn when toggle fails after register', async () => {
|
||||
mockSkillGetSkillDirectory.mockReturnValue('/global-skills/my-skill')
|
||||
mockReaddir.mockResolvedValue(['SKILL.md'])
|
||||
mockSkillInstallFromDirectory.mockResolvedValue({
|
||||
id: 'skill-2',
|
||||
name: 'My Skill',
|
||||
description: 'Cool skill',
|
||||
folderName: 'my-skill',
|
||||
isEnabled: false
|
||||
})
|
||||
mockSkillToggle.mockResolvedValue(null)
|
||||
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'register', name: 'my-skill' })
|
||||
|
||||
expect(result.content[0].text).toContain('warning: failed to enable')
|
||||
expect(result.content[0].text).toContain('Enabled: false')
|
||||
})
|
||||
|
||||
it('should error when name is missing', async () => {
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'register' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain("'name' is required")
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle unknown action', async () => {
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'unknown' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain('Unknown action')
|
||||
})
|
||||
})
|
||||
192
src/main/mcpServers/__tests__/workspaceMemory.test.ts
Normal file
192
src/main/mcpServers/__tests__/workspaceMemory.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockGetAgent = vi.fn()
|
||||
const mockMkdir = vi.fn()
|
||||
const mockWriteFile = vi.fn()
|
||||
const mockRename = vi.fn()
|
||||
const mockAppendFile = vi.fn()
|
||||
const mockReadFile = vi.fn()
|
||||
const mockReaddir = vi.fn()
|
||||
const mockStat = vi.fn()
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
mkdir: (...args: unknown[]) => mockMkdir(...args),
|
||||
writeFile: (...args: unknown[]) => mockWriteFile(...args),
|
||||
rename: (...args: unknown[]) => mockRename(...args),
|
||||
appendFile: (...args: unknown[]) => mockAppendFile(...args),
|
||||
readFile: (...args: unknown[]) => mockReadFile(...args),
|
||||
readdir: (...args: unknown[]) => mockReaddir(...args),
|
||||
stat: (...args: unknown[]) => mockStat(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@main/services/agents/services/AgentService', () => ({
|
||||
agentService: {
|
||||
getAgent: mockGetAgent
|
||||
}
|
||||
}))
|
||||
|
||||
const { default: WorkspaceMemoryServer } = await import('../workspaceMemory')
|
||||
type WorkspaceMemoryServerInstance = InstanceType<typeof WorkspaceMemoryServer>
|
||||
|
||||
function createServer(agentId = 'agent_test') {
|
||||
return new WorkspaceMemoryServer(agentId)
|
||||
}
|
||||
|
||||
async function callTool(server: WorkspaceMemoryServerInstance, args: Record<string, unknown>) {
|
||||
const handlers = (server.mcpServer.server as any)._requestHandlers
|
||||
const callToolHandler = handlers?.get('tools/call')
|
||||
if (!callToolHandler) {
|
||||
throw new Error('No tools/call handler registered')
|
||||
}
|
||||
return callToolHandler({ method: 'tools/call', params: { name: 'memory', arguments: args } }, {})
|
||||
}
|
||||
|
||||
async function listTools(server: WorkspaceMemoryServerInstance) {
|
||||
const handlers = (server.mcpServer.server as any)._requestHandlers
|
||||
const listHandler = handlers?.get('tools/list')
|
||||
if (!listHandler) {
|
||||
throw new Error('No tools/list handler registered')
|
||||
}
|
||||
return listHandler({ method: 'tools/list', params: {} }, {})
|
||||
}
|
||||
|
||||
describe('WorkspaceMemoryServer', () => {
|
||||
const agentWithWorkspace = { accessible_paths: ['/workspace/test'] }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetAgent.mockResolvedValue(agentWithWorkspace)
|
||||
mockMkdir.mockResolvedValue(undefined)
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
mockRename.mockResolvedValue(undefined)
|
||||
mockAppendFile.mockResolvedValue(undefined)
|
||||
// resolveFileCI: exact path always found
|
||||
mockStat.mockResolvedValue({ mtimeMs: 1000 })
|
||||
})
|
||||
|
||||
it('should expose only the memory tool', async () => {
|
||||
const server = createServer()
|
||||
const result = await listTools(server)
|
||||
expect(result.tools).toHaveLength(1)
|
||||
expect(result.tools[0].name).toBe('memory')
|
||||
})
|
||||
|
||||
describe('update action', () => {
|
||||
it('should update FACT.md atomically', async () => {
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'update', content: '# Facts\n\nNew knowledge' })
|
||||
|
||||
expect(mockMkdir).toHaveBeenCalledWith('/workspace/test/memory', { recursive: true })
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('FACT.md.'),
|
||||
'# Facts\n\nNew knowledge',
|
||||
'utf-8'
|
||||
)
|
||||
expect(mockRename).toHaveBeenCalled()
|
||||
expect(result.content[0].text).toBe('Memory updated.')
|
||||
})
|
||||
|
||||
it('should error when content is missing', async () => {
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'update' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain("'content' is required")
|
||||
})
|
||||
})
|
||||
|
||||
describe('append action', () => {
|
||||
it('should append journal entry with tags', async () => {
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, {
|
||||
action: 'append',
|
||||
text: 'Deployed v2.0',
|
||||
tags: ['deploy', 'release']
|
||||
})
|
||||
|
||||
expect(mockAppendFile).toHaveBeenCalledWith(
|
||||
'/workspace/test/memory/JOURNAL.jsonl',
|
||||
expect.stringContaining('"text":"Deployed v2.0"'),
|
||||
'utf-8'
|
||||
)
|
||||
expect(result.content[0].text).toContain('Journal entry added')
|
||||
})
|
||||
|
||||
it('should error when text is missing', async () => {
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'append' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain("'text' is required")
|
||||
})
|
||||
})
|
||||
|
||||
describe('search action', () => {
|
||||
it('should search journal by tag', async () => {
|
||||
const entries = [
|
||||
'{"ts":"2024-01-01T00:00:00Z","tags":["deploy"],"text":"Deployed v1.0"}',
|
||||
'{"ts":"2024-01-02T00:00:00Z","tags":["bugfix"],"text":"Fixed login bug"}',
|
||||
'{"ts":"2024-01-03T00:00:00Z","tags":["deploy"],"text":"Deployed v2.0"}'
|
||||
].join('\n')
|
||||
mockReadFile.mockResolvedValue(entries)
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'search', tag: 'deploy' })
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text)
|
||||
expect(parsed).toHaveLength(2)
|
||||
expect(parsed[0].text).toBe('Deployed v2.0') // reverse chronological
|
||||
})
|
||||
|
||||
it('should search journal with text query', async () => {
|
||||
const entries = [
|
||||
'{"ts":"2024-01-01T00:00:00Z","tags":[],"text":"Setup project"}',
|
||||
'{"ts":"2024-01-02T00:00:00Z","tags":[],"text":"Fixed login bug"}'
|
||||
].join('\n')
|
||||
mockReadFile.mockResolvedValue(entries)
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'search', query: 'login' })
|
||||
|
||||
const parsed = JSON.parse(result.content[0].text)
|
||||
expect(parsed).toHaveLength(1)
|
||||
expect(parsed[0].text).toBe('Fixed login bug')
|
||||
})
|
||||
|
||||
it('should return message when no matches', async () => {
|
||||
mockReadFile.mockResolvedValue('{"ts":"2024-01-01T00:00:00Z","tags":[],"text":"hello"}\n')
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'search', query: 'nonexistent' })
|
||||
|
||||
expect(result.content[0].text).toBe('No matching journal entries found.')
|
||||
})
|
||||
|
||||
it('should return message when journal does not exist', async () => {
|
||||
mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'search' })
|
||||
|
||||
expect(result.content[0].text).toBe('No journal entries found.')
|
||||
})
|
||||
})
|
||||
|
||||
it('should error when agent has no workspace', async () => {
|
||||
mockGetAgent.mockResolvedValue({ accessible_paths: [] })
|
||||
|
||||
const server = createServer('agent_1')
|
||||
const result = await callTool(server, { action: 'update', content: 'test' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain('no workspace path')
|
||||
})
|
||||
|
||||
it('should handle unknown action', async () => {
|
||||
const server = createServer()
|
||||
const result = await callTool(server, { action: 'unknown' })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toContain('Unknown action')
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,13 @@
|
||||
import { appendFile, mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { type ChannelConfig, ChannelConfigSchema } from '@main/services/agents/database/schema'
|
||||
import { agentService } from '@main/services/agents/services/AgentService'
|
||||
import { channelManager } from '@main/services/agents/services/channels/ChannelManager'
|
||||
import { channelService } from '@main/services/agents/services/ChannelService'
|
||||
import { taskService } from '@main/services/agents/services/TaskService'
|
||||
import { skillService } from '@main/services/agents/skills'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'
|
||||
import type { CherryClawConfiguration, TaskScheduleType } from '@types'
|
||||
import { net } from 'electron'
|
||||
import type { AgentConfiguration, TaskScheduleType } from '@types'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const logger = loggerService.withContext('MCPServer:Claw')
|
||||
@@ -37,39 +32,6 @@ function parseDurationToMinutes(duration: string): number {
|
||||
return totalMinutes
|
||||
}
|
||||
|
||||
type SkillSearchResult = {
|
||||
name: string
|
||||
namespace?: string
|
||||
description?: string | null
|
||||
author?: string | null
|
||||
installs?: number
|
||||
metadata?: {
|
||||
repoOwner?: string
|
||||
repoName?: string
|
||||
}
|
||||
}
|
||||
|
||||
function buildSkillIdentifier(skill: SkillSearchResult): string {
|
||||
const { name, namespace, metadata } = skill
|
||||
const repoOwner = metadata?.repoOwner
|
||||
const repoName = metadata?.repoName
|
||||
|
||||
if (repoOwner && repoName) {
|
||||
return `${repoOwner}/${repoName}/${name}`
|
||||
}
|
||||
|
||||
if (namespace) {
|
||||
const cleanNamespace = namespace.replace(/^@/, '')
|
||||
const parts = cleanNamespace.split('/').filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]}/${parts[1]}/${name}`
|
||||
}
|
||||
return `${cleanNamespace}/${name}`
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
const CRON_TOOL: Tool = {
|
||||
name: 'cron',
|
||||
description:
|
||||
@@ -138,67 +100,6 @@ const NOTIFY_TOOL: Tool = {
|
||||
}
|
||||
}
|
||||
|
||||
const MARKETPLACE_BASE_URL = 'https://claude-plugins.dev'
|
||||
|
||||
const SKILLS_TOOL: Tool = {
|
||||
name: 'skills',
|
||||
description:
|
||||
"Manage Claude skills in the agent's workspace. Use action 'search' to find skills from the marketplace, 'install' to install a skill, 'remove' to uninstall a skill, or 'list' to see installed skills.",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['search', 'install', 'remove', 'list'],
|
||||
description: 'The action to perform'
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
description: "Search query for finding skills in the marketplace (required for 'search')"
|
||||
},
|
||||
identifier: {
|
||||
type: 'string',
|
||||
description:
|
||||
"Marketplace skill identifier in 'owner/repo/skill-name' format (required for 'install'). Get this from the search results."
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: "Skill folder name to remove (required for 'remove'). Get this from the list results."
|
||||
}
|
||||
},
|
||||
required: ['action']
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a filename within a directory using case-insensitive matching.
|
||||
* Returns the full path if found (preferring exact match), or the canonical path as fallback.
|
||||
*/
|
||||
async function resolveFileCI(dir: string, name: string): Promise<string> {
|
||||
const exact = path.join(dir, name)
|
||||
try {
|
||||
await stat(exact)
|
||||
return exact
|
||||
} catch {
|
||||
// exact match not found, try case-insensitive
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(dir)
|
||||
const target = name.toLowerCase()
|
||||
const match = entries.find((e) => e.toLowerCase() === target)
|
||||
return match ? path.join(dir, match) : exact
|
||||
} catch {
|
||||
return exact
|
||||
}
|
||||
}
|
||||
|
||||
type JournalEntry = {
|
||||
ts: string
|
||||
tags: string[]
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Per-adapter-type config schema descriptions (for agent self-documentation). */
|
||||
const CHANNEL_CONFIG_SCHEMAS: Record<string, { required: string[]; optional: string[]; description: string }> = {
|
||||
telegram: {
|
||||
@@ -301,49 +202,6 @@ const CONFIG_TOOL: Tool = {
|
||||
}
|
||||
}
|
||||
|
||||
const MEMORY_TOOL: Tool = {
|
||||
name: 'memory',
|
||||
description:
|
||||
"Manage persistent memory across sessions. Actions: 'update' overwrites memory/FACT.md (only durable project knowledge and decisions — not user preferences or personality, those belong in user.md and soul.md). 'append' logs to memory/JOURNAL.jsonl (one-time events, completed tasks, session notes). 'search' queries the journal. Before writing to FACT.md, ask: will this still matter in 6 months? If not, use append instead.",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['update', 'append', 'search'],
|
||||
description:
|
||||
"Action to perform: 'update' overwrites FACT.md (durable project knowledge only), 'append' adds a JOURNAL entry, 'search' queries the journal"
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'Full markdown content for FACT.md (required for update)'
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
description: 'Journal entry text (required for append)'
|
||||
},
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Tags for the journal entry (optional, for append)'
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query — case-insensitive substring match (for search)'
|
||||
},
|
||||
tag: {
|
||||
type: 'string',
|
||||
description: 'Filter by tag (optional, for search)'
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
description: 'Max results to return (default 20, for search)'
|
||||
}
|
||||
},
|
||||
required: ['action']
|
||||
}
|
||||
}
|
||||
|
||||
class ClawServer {
|
||||
public mcpServer: McpServer
|
||||
private agentId: string
|
||||
@@ -368,7 +226,7 @@ class ClawServer {
|
||||
|
||||
private setupHandlers() {
|
||||
this.mcpServer.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [CRON_TOOL, NOTIFY_TOOL, SKILLS_TOOL, MEMORY_TOOL, CONFIG_TOOL]
|
||||
tools: [CRON_TOOL, NOTIFY_TOOL, CONFIG_TOOL]
|
||||
}))
|
||||
|
||||
this.mcpServer.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
@@ -392,37 +250,6 @@ class ClawServer {
|
||||
}
|
||||
case 'notify':
|
||||
return await this.sendNotification(args)
|
||||
case 'skills': {
|
||||
const action = args.action
|
||||
switch (action) {
|
||||
case 'search':
|
||||
return await this.searchSkills(args)
|
||||
case 'install':
|
||||
return await this.installSkill(args)
|
||||
case 'remove':
|
||||
return await this.removeSkill(args)
|
||||
case 'list':
|
||||
return await this.listSkills()
|
||||
default:
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Unknown action "${action}", expected search/install/remove/list`
|
||||
)
|
||||
}
|
||||
}
|
||||
case 'memory': {
|
||||
const action = args.action
|
||||
switch (action) {
|
||||
case 'update':
|
||||
return await this.memoryUpdate(args)
|
||||
case 'append':
|
||||
return await this.memoryAppend(args)
|
||||
case 'search':
|
||||
return await this.memorySearch(args)
|
||||
default:
|
||||
throw new McpError(ErrorCode.InvalidParams, `Unknown action "${action}", expected update/append/search`)
|
||||
}
|
||||
}
|
||||
case 'config': {
|
||||
const action = args.action
|
||||
switch (action) {
|
||||
@@ -583,210 +410,6 @@ class ClawServer {
|
||||
}
|
||||
}
|
||||
|
||||
private async searchSkills(args: Record<string, string | undefined>) {
|
||||
const query = args.query
|
||||
if (!query) throw new McpError(ErrorCode.InvalidParams, "'query' is required for search")
|
||||
|
||||
const url = new URL(`${MARKETPLACE_BASE_URL}/api/skills`)
|
||||
url.searchParams.set('q', query.replace(/[-_]+/g, ' ').trim())
|
||||
url.searchParams.set('limit', '20')
|
||||
url.searchParams.set('offset', '0')
|
||||
|
||||
const response = await net.fetch(url.toString(), { method: 'GET' })
|
||||
if (!response.ok) {
|
||||
throw new Error(`Marketplace API returned ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const json = (await response.json()) as { skills?: SkillSearchResult[]; total?: number }
|
||||
const skills = json.skills ?? []
|
||||
|
||||
if (skills.length === 0) {
|
||||
return { content: [{ type: 'text' as const, text: `No skills found for "${query}".` }] }
|
||||
}
|
||||
|
||||
const results = skills.map((s) => ({
|
||||
name: s.name,
|
||||
description: s.description ?? null,
|
||||
author: s.author ?? null,
|
||||
identifier: buildSkillIdentifier(s),
|
||||
installs: s.installs ?? 0
|
||||
}))
|
||||
|
||||
logger.info('Skills search via tool', { agentId: this.agentId, query, resultCount: results.length })
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Found ${results.length} skill(s) for "${query}":\n${JSON.stringify(results, null, 2)}\n\nUse the 'identifier' field with action 'install' to install a skill.`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private async installSkill(args: Record<string, string | undefined>) {
|
||||
const identifier = args.identifier
|
||||
if (!identifier) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
"'identifier' is required for install (format: 'owner/repo/skill-name')"
|
||||
)
|
||||
}
|
||||
|
||||
const installed = await skillService.install({
|
||||
installSource: `claude-plugins:${identifier}`
|
||||
})
|
||||
|
||||
logger.info('Skill installed via tool', { agentId: this.agentId, identifier, name: installed.name })
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Skill installed:\n Name: ${installed.name}\n Description: ${installed.description ?? 'N/A'}\n Folder: ${installed.folderName}`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private async removeSkill(args: Record<string, string | undefined>) {
|
||||
const name = args.name
|
||||
if (!name) throw new McpError(ErrorCode.InvalidParams, "'name' is required for remove (skill folder name)")
|
||||
|
||||
await skillService.uninstallByFolderName(name)
|
||||
|
||||
logger.info('Skill removed via tool', { agentId: this.agentId, name })
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Skill "${name}" removed.` }]
|
||||
}
|
||||
}
|
||||
|
||||
private async listSkills() {
|
||||
const skills = await skillService.list()
|
||||
|
||||
if (skills.length === 0) {
|
||||
return { content: [{ type: 'text' as const, text: 'No skills installed.' }] }
|
||||
}
|
||||
|
||||
const results = skills.map((s) => ({
|
||||
name: s.name,
|
||||
folder: s.folderName,
|
||||
description: s.description ?? null,
|
||||
enabled: s.isEnabled
|
||||
}))
|
||||
|
||||
logger.info('Skills list via tool', { agentId: this.agentId, count: results.length })
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }]
|
||||
}
|
||||
}
|
||||
|
||||
private async getWorkspacePath(): Promise<string> {
|
||||
const agent = await agentService.getAgent(this.agentId)
|
||||
if (!agent) throw new McpError(ErrorCode.InternalError, `Agent not found: ${this.agentId}`)
|
||||
const workspace = agent.accessible_paths?.[0]
|
||||
if (!workspace) throw new McpError(ErrorCode.InternalError, 'Agent has no workspace path configured')
|
||||
return workspace
|
||||
}
|
||||
|
||||
private async memoryUpdate(args: Record<string, string | undefined>) {
|
||||
const content = args.content
|
||||
if (!content) throw new McpError(ErrorCode.InvalidParams, "'content' is required for update action")
|
||||
|
||||
const workspace = await this.getWorkspacePath()
|
||||
const memoryDir = path.join(workspace, 'memory')
|
||||
const factPath = await resolveFileCI(memoryDir, 'FACT.md')
|
||||
|
||||
await mkdir(memoryDir, { recursive: true })
|
||||
|
||||
// Atomic write via temp file + rename
|
||||
const tmpPath = `${factPath}.${Date.now()}.tmp`
|
||||
await writeFile(tmpPath, content, 'utf-8')
|
||||
await rename(tmpPath, factPath)
|
||||
|
||||
logger.info('Memory FACT.md updated via tool', { agentId: this.agentId, length: content.length })
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Memory updated.' }]
|
||||
}
|
||||
}
|
||||
|
||||
private async memoryAppend(args: Record<string, string | undefined>) {
|
||||
const text = args.text
|
||||
if (!text) throw new McpError(ErrorCode.InvalidParams, "'text' is required for append action")
|
||||
|
||||
const tags: string[] = []
|
||||
const rawTags = (args as Record<string, unknown>).tags
|
||||
if (Array.isArray(rawTags)) {
|
||||
for (const item of rawTags) {
|
||||
if (typeof item === 'string') tags.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
const workspace = await this.getWorkspacePath()
|
||||
const memoryDir = path.join(workspace, 'memory')
|
||||
|
||||
await mkdir(memoryDir, { recursive: true })
|
||||
|
||||
const journalPath = await resolveFileCI(memoryDir, 'JOURNAL.jsonl')
|
||||
|
||||
const entry: JournalEntry = {
|
||||
ts: new Date().toISOString(),
|
||||
tags,
|
||||
text
|
||||
}
|
||||
|
||||
await appendFile(journalPath, JSON.stringify(entry) + '\n', 'utf-8')
|
||||
|
||||
logger.info('Journal entry appended via tool', { agentId: this.agentId, tags })
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Journal entry added at ${entry.ts}.` }]
|
||||
}
|
||||
}
|
||||
|
||||
private async memorySearch(args: Record<string, string | undefined>) {
|
||||
const query = args.query ?? ''
|
||||
const tagFilter = args.tag ?? ''
|
||||
const limit = Math.max(1, parseInt(args.limit ?? '20', 10) || 20)
|
||||
|
||||
const workspace = await this.getWorkspacePath()
|
||||
const memoryDir = path.join(workspace, 'memory')
|
||||
const journalPath = await resolveFileCI(memoryDir, 'JOURNAL.jsonl')
|
||||
|
||||
let fileContent: string
|
||||
try {
|
||||
fileContent = await readFile(journalPath, 'utf-8')
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'No journal entries found.' }] }
|
||||
}
|
||||
|
||||
const queryLower = query.toLowerCase()
|
||||
const tagLower = tagFilter.toLowerCase()
|
||||
const matches: JournalEntry[] = []
|
||||
|
||||
for (const line of fileContent.split('\n')) {
|
||||
if (!line.trim()) continue
|
||||
let entry: JournalEntry
|
||||
try {
|
||||
entry = JSON.parse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (tagFilter && !entry.tags?.some((t) => t.toLowerCase() === tagLower)) continue
|
||||
if (query && !entry.text.toLowerCase().includes(queryLower)) continue
|
||||
matches.push(entry)
|
||||
}
|
||||
|
||||
// Return last N entries in reverse-chronological order
|
||||
const result = matches.slice(-limit).reverse()
|
||||
|
||||
if (result.length === 0) {
|
||||
return { content: [{ type: 'text' as const, text: 'No matching journal entries found.' }] }
|
||||
}
|
||||
|
||||
logger.info('Journal search via tool', { agentId: this.agentId, query, tag: tagFilter, resultCount: result.length })
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config tool handlers ──────────────────────────────────────────
|
||||
|
||||
private async configStatus() {
|
||||
@@ -1060,7 +683,7 @@ class ClawServer {
|
||||
|
||||
const existingConfig = agent.configuration
|
||||
await agentService.updateAgent(this.agentId, {
|
||||
configuration: { ...existingConfig, bootstrap_completed: true } as CherryClawConfiguration
|
||||
configuration: { ...existingConfig, bootstrap_completed: true } as AgentConfiguration
|
||||
})
|
||||
|
||||
logger.info('Bootstrap marked as completed', { agentId: this.agentId })
|
||||
@@ -1077,7 +700,7 @@ class ClawServer {
|
||||
|
||||
const existingConfig = agent.configuration
|
||||
await agentService.updateAgent(this.agentId, {
|
||||
configuration: { ...existingConfig, bootstrap_completed: false } as CherryClawConfiguration
|
||||
configuration: { ...existingConfig, bootstrap_completed: false } as AgentConfiguration
|
||||
})
|
||||
|
||||
logger.info('Bootstrap reset', { agentId: this.agentId })
|
||||
|
||||
364
src/main/mcpServers/skills.ts
Normal file
364
src/main/mcpServers/skills.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { mkdir, readdir } from 'node:fs/promises'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { skillService } from '@main/services/agents/skills'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { net } from 'electron'
|
||||
|
||||
const logger = loggerService.withContext('MCPServer:Skills')
|
||||
|
||||
const MARKETPLACE_BASE_URL = 'https://claude-plugins.dev'
|
||||
|
||||
type SkillSearchResult = {
|
||||
name: string
|
||||
namespace?: string
|
||||
description?: string | null
|
||||
author?: string | null
|
||||
installs?: number
|
||||
metadata?: {
|
||||
repoOwner?: string
|
||||
repoName?: string
|
||||
}
|
||||
}
|
||||
|
||||
function buildSkillIdentifier(skill: SkillSearchResult): string {
|
||||
const { name, namespace, metadata } = skill
|
||||
const repoOwner = metadata?.repoOwner
|
||||
const repoName = metadata?.repoName
|
||||
|
||||
if (repoOwner && repoName) {
|
||||
return `${repoOwner}/${repoName}/${name}`
|
||||
}
|
||||
|
||||
if (namespace) {
|
||||
const cleanNamespace = namespace.replace(/^@/, '')
|
||||
const parts = cleanNamespace.split('/').filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]}/${parts[1]}/${name}`
|
||||
}
|
||||
return `${cleanNamespace}/${name}`
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
const SKILLS_TOOL: Tool = {
|
||||
name: 'skills',
|
||||
description:
|
||||
"Manage Claude skills. Use 'search' to find skills from the marketplace, 'install' to install a marketplace skill, 'remove' to uninstall, or 'list' to see installed skills. To author a brand-new skill, use 'init' to prepare a target directory, write SKILL.md and supporting files into that directory, then call 'register' to add it to the global skill list and enable it for the current session.",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['search', 'install', 'remove', 'list', 'init', 'register'],
|
||||
description: 'The action to perform'
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
description: "Search query for finding skills in the marketplace (required for 'search')"
|
||||
},
|
||||
identifier: {
|
||||
type: 'string',
|
||||
description:
|
||||
"Marketplace skill identifier in 'owner/repo/skill-name' format (required for 'install'). Get this from the search results."
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description:
|
||||
"Skill folder name. Required for 'remove' (from list results), 'init' (the new skill's folder name), and 'register' (same name passed to init)."
|
||||
}
|
||||
},
|
||||
required: ['action']
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP server exposing skill management to any agent (not gated on Soul Mode).
|
||||
*
|
||||
* Skills are a generally useful capability — searching the marketplace,
|
||||
* installing, listing, and authoring skills via init/register applies to
|
||||
* regular chat agents and autonomous agents alike.
|
||||
*/
|
||||
class SkillsServer {
|
||||
public mcpServer: McpServer
|
||||
private agentId: string
|
||||
|
||||
constructor(agentId: string) {
|
||||
this.agentId = agentId
|
||||
this.mcpServer = new McpServer(
|
||||
{
|
||||
name: 'skills',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
this.setupHandlers()
|
||||
}
|
||||
|
||||
private setupHandlers() {
|
||||
this.mcpServer.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [SKILLS_TOOL]
|
||||
}))
|
||||
|
||||
this.mcpServer.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const toolName = request.params.name
|
||||
const args = (request.params.arguments ?? {}) as Record<string, string | undefined>
|
||||
|
||||
try {
|
||||
if (toolName !== 'skills') {
|
||||
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`)
|
||||
}
|
||||
const action = args.action
|
||||
switch (action) {
|
||||
case 'search':
|
||||
return await this.searchSkills(args)
|
||||
case 'install':
|
||||
return await this.installSkill(args)
|
||||
case 'remove':
|
||||
return await this.removeSkill(args)
|
||||
case 'list':
|
||||
return await this.listSkills()
|
||||
case 'init':
|
||||
return await this.initSkill(args)
|
||||
case 'register':
|
||||
return await this.registerSkill(args)
|
||||
default:
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Unknown action "${action}", expected search/install/remove/list/init/register`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger.error(`Tool error: ${toolName}`, { agentId: this.agentId, error: message })
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${message}` }],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async searchSkills(args: Record<string, string | undefined>) {
|
||||
const query = args.query
|
||||
if (!query) throw new McpError(ErrorCode.InvalidParams, "'query' is required for search")
|
||||
|
||||
const url = new URL(`${MARKETPLACE_BASE_URL}/api/skills`)
|
||||
url.searchParams.set('q', query.replace(/[-_]+/g, ' ').trim())
|
||||
url.searchParams.set('limit', '20')
|
||||
url.searchParams.set('offset', '0')
|
||||
|
||||
const response = await net.fetch(url.toString(), { method: 'GET' })
|
||||
if (!response.ok) {
|
||||
throw new Error(`Marketplace API returned ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const json = (await response.json()) as { skills?: SkillSearchResult[]; total?: number }
|
||||
const skills = json.skills ?? []
|
||||
|
||||
if (skills.length === 0) {
|
||||
return { content: [{ type: 'text' as const, text: `No skills found for "${query}".` }] }
|
||||
}
|
||||
|
||||
const results = skills.map((s) => ({
|
||||
name: s.name,
|
||||
description: s.description ?? null,
|
||||
author: s.author ?? null,
|
||||
identifier: buildSkillIdentifier(s),
|
||||
installs: s.installs ?? 0
|
||||
}))
|
||||
|
||||
logger.info('Skills search via tool', { agentId: this.agentId, query, resultCount: results.length })
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Found ${results.length} skill(s) for "${query}":\n${JSON.stringify(results, null, 2)}\n\nUse the 'identifier' field with action 'install' to install a skill.`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private async installSkill(args: Record<string, string | undefined>) {
|
||||
const identifier = args.identifier
|
||||
if (!identifier) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
"'identifier' is required for install (format: 'owner/repo/skill-name')"
|
||||
)
|
||||
}
|
||||
|
||||
const installed = await skillService.install({
|
||||
installSource: `claude-plugins:${identifier}`
|
||||
})
|
||||
const enabled = await skillService.toggle({ skillId: installed.id, isEnabled: true })
|
||||
|
||||
logger.info('Skill installed via tool', { agentId: this.agentId, identifier, name: installed.name })
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Skill installed${enabled?.isEnabled ? ' and enabled' : ' (warning: failed to enable)'}:\n Name: ${installed.name}\n Description: ${installed.description ?? 'N/A'}\n Folder: ${installed.folderName}\n Enabled: ${enabled?.isEnabled ?? false}`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private async removeSkill(args: Record<string, string | undefined>) {
|
||||
const name = args.name
|
||||
if (!name) throw new McpError(ErrorCode.InvalidParams, "'name' is required for remove (skill folder name)")
|
||||
|
||||
await skillService.uninstallByFolderName(name)
|
||||
|
||||
logger.info('Skill removed via tool', { agentId: this.agentId, name })
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Skill "${name}" removed.` }]
|
||||
}
|
||||
}
|
||||
|
||||
private async listSkills() {
|
||||
const skills = await skillService.list()
|
||||
|
||||
if (skills.length === 0) {
|
||||
return { content: [{ type: 'text' as const, text: 'No skills installed.' }] }
|
||||
}
|
||||
|
||||
// Include the absolute on-disk path so the model can patch a skill in
|
||||
// place via the native Read / Edit tools when it discovers the skill is
|
||||
// outdated, incomplete, or wrong (the live symlink picks up file edits
|
||||
// immediately, so no separate "patch" tool is needed).
|
||||
const results = skills.map((s) => ({
|
||||
name: s.name,
|
||||
folder: s.folderName,
|
||||
path: skillService.getSkillDirectory(s.folderName),
|
||||
description: s.description ?? null,
|
||||
enabled: s.isEnabled
|
||||
}))
|
||||
|
||||
logger.info('Skills list via tool', { agentId: this.agentId, count: results.length })
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }]
|
||||
}
|
||||
}
|
||||
|
||||
private async initSkill(args: Record<string, string | undefined>) {
|
||||
const name = args.name
|
||||
if (!name) throw new McpError(ErrorCode.InvalidParams, "'name' is required for init")
|
||||
|
||||
const skillDir = skillService.getSkillDirectory(name)
|
||||
|
||||
// Check for collision with an existing skill in DB.
|
||||
const existingSkill = await skillService.getByFolderName(name)
|
||||
if (existingSkill) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`A skill named "${existingSkill.name}" already exists with folder "${name}". ` +
|
||||
`Choose a different name, or use action="remove" with name="${name}" first if you intend to replace it.`
|
||||
)
|
||||
}
|
||||
|
||||
// Guard against an orphaned non-empty directory that isn't tracked in the DB.
|
||||
let dirHasContent = false
|
||||
try {
|
||||
const entries = await readdir(skillDir)
|
||||
dirHasContent = entries.length > 0
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
if (code !== 'ENOENT') {
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`Cannot read skill directory "${skillDir}": ${(err as Error).message}`
|
||||
)
|
||||
}
|
||||
// Directory doesn't exist yet — safe to create.
|
||||
}
|
||||
if (dirHasContent) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`The directory "${skillDir}" already exists and is non-empty but is not tracked in the skill database. ` +
|
||||
`Choose a different name, or manually remove the directory before calling init.`
|
||||
)
|
||||
}
|
||||
|
||||
await mkdir(skillDir, { recursive: true })
|
||||
|
||||
logger.info('Skill directory initialized via tool', { agentId: this.agentId, name, skillDir })
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: [
|
||||
`Skill directory ready at:`,
|
||||
skillDir,
|
||||
``,
|
||||
`Write SKILL.md and any supporting files (scripts/, references/, assets/) directly into this directory.`,
|
||||
`When the skill is ready, call skills with action="register" and name="${name}" to register it in the global skill list and enable it for the current session.`,
|
||||
`You can re-edit files in place and call register again to refresh.`
|
||||
].join('\n')
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private async registerSkill(args: Record<string, string | undefined>) {
|
||||
const name = args.name
|
||||
if (!name) throw new McpError(ErrorCode.InvalidParams, "'name' is required for register")
|
||||
|
||||
const skillDir = skillService.getSkillDirectory(name)
|
||||
|
||||
// Pre-flight: ensure SKILL.md exists before attempting install
|
||||
try {
|
||||
const entries = await readdir(skillDir)
|
||||
if (!entries.some((e) => e.toLowerCase() === 'skill.md')) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`No SKILL.md found in "${skillDir}". Call action="init" first and write a SKILL.md file before registering.`
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof McpError) throw err
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
if (code === 'ENOENT') {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Skill directory "${skillDir}" does not exist. Did you call action="init" first?`
|
||||
)
|
||||
}
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`Cannot read skill directory "${skillDir}": ${(err as Error).message}`
|
||||
)
|
||||
}
|
||||
|
||||
const installed = await skillService.installFromDirectory({ directoryPath: skillDir })
|
||||
const enabled = await skillService.toggle({ skillId: installed.id, isEnabled: true })
|
||||
|
||||
logger.info('Skill registered via tool', {
|
||||
agentId: this.agentId,
|
||||
name: installed.name,
|
||||
folderName: installed.folderName
|
||||
})
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: [
|
||||
`Skill "${installed.name}" registered${enabled?.isEnabled ? ' and enabled' : ' (warning: failed to enable)'}.`,
|
||||
` Folder: ${installed.folderName}`,
|
||||
` Description: ${installed.description ?? 'N/A'}`,
|
||||
` Enabled: ${enabled?.isEnabled ?? false}`
|
||||
].join('\n')
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SkillsServer
|
||||
270
src/main/mcpServers/workspaceMemory.ts
Normal file
270
src/main/mcpServers/workspaceMemory.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { appendFile, mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { agentService } from '@main/services/agents/services/AgentService'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
const logger = loggerService.withContext('MCPServer:WorkspaceMemory')
|
||||
|
||||
/**
|
||||
* Resolve a filename within a directory using case-insensitive matching.
|
||||
* Returns the full path if found (preferring exact match), or the canonical path as fallback.
|
||||
*/
|
||||
async function resolveFileCI(dir: string, name: string): Promise<string> {
|
||||
const exact = path.join(dir, name)
|
||||
try {
|
||||
await stat(exact)
|
||||
return exact
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.warn('Unexpected error checking file', { path: exact, error: (err as Error).message })
|
||||
}
|
||||
// exact match not found, try case-insensitive
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(dir)
|
||||
const target = name.toLowerCase()
|
||||
const match = entries.find((e) => e.toLowerCase() === target)
|
||||
return match ? path.join(dir, match) : exact
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.warn('Unexpected error reading directory', { dir, error: (err as Error).message })
|
||||
}
|
||||
return exact
|
||||
}
|
||||
}
|
||||
|
||||
type JournalEntry = {
|
||||
ts: string
|
||||
tags: string[]
|
||||
text: string
|
||||
}
|
||||
|
||||
const MEMORY_TOOL: Tool = {
|
||||
name: 'memory',
|
||||
description:
|
||||
"Manage persistent memory in this agent's workspace across sessions. Actions: 'update' overwrites memory/FACT.md (durable knowledge and decisions that should survive across sessions). 'append' logs to memory/JOURNAL.jsonl (one-time events, completed tasks, session notes). 'search' queries the journal. Before writing to FACT.md, ask: will this still matter in 6 months? If not, use append instead.",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['update', 'append', 'search'],
|
||||
description:
|
||||
"Action to perform: 'update' overwrites FACT.md (durable knowledge only), 'append' adds a JOURNAL entry, 'search' queries the journal"
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'Full markdown content for FACT.md (required for update)'
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
description: 'Journal entry text (required for append)'
|
||||
},
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Tags for the journal entry (optional, for append)'
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query — case-insensitive substring match (for search)'
|
||||
},
|
||||
tag: {
|
||||
type: 'string',
|
||||
description: 'Filter by tag (optional, for search)'
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
description: 'Max results to return (default 20, for search)'
|
||||
}
|
||||
},
|
||||
required: ['action']
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP server exposing cross-session memory to any agent (not gated on Soul Mode).
|
||||
*
|
||||
* Memory lives in the agent's workspace under `memory/` — `FACT.md` for
|
||||
* durable knowledge and `JOURNAL.jsonl` for timestamped events. Any agent
|
||||
* with a stable workspace benefits from this; the tool itself is just a
|
||||
* thin, safe wrapper over file operations.
|
||||
*
|
||||
* Distinct from the built-in `memory.ts` knowledge-graph server, which is
|
||||
* a user-opt-in MCP that stores entity/relation graphs in a global JSON
|
||||
* file rather than in the agent's workspace.
|
||||
*/
|
||||
class WorkspaceMemoryServer {
|
||||
public mcpServer: McpServer
|
||||
private agentId: string
|
||||
|
||||
constructor(agentId: string) {
|
||||
this.agentId = agentId
|
||||
this.mcpServer = new McpServer(
|
||||
{
|
||||
name: 'agent-memory',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
this.setupHandlers()
|
||||
}
|
||||
|
||||
private setupHandlers() {
|
||||
this.mcpServer.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [MEMORY_TOOL]
|
||||
}))
|
||||
|
||||
this.mcpServer.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const toolName = request.params.name
|
||||
const args = (request.params.arguments ?? {}) as Record<string, string | undefined>
|
||||
|
||||
try {
|
||||
if (toolName !== 'memory') {
|
||||
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`)
|
||||
}
|
||||
const action = args.action
|
||||
switch (action) {
|
||||
case 'update':
|
||||
return await this.memoryUpdate(args)
|
||||
case 'append':
|
||||
return await this.memoryAppend(args)
|
||||
case 'search':
|
||||
return await this.memorySearch(args)
|
||||
default:
|
||||
throw new McpError(ErrorCode.InvalidParams, `Unknown action "${action}", expected update/append/search`)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger.error(`Tool error: ${toolName}`, { agentId: this.agentId, error: message })
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${message}` }],
|
||||
isError: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async getWorkspacePath(): Promise<string> {
|
||||
const agent = await agentService.getAgent(this.agentId)
|
||||
if (!agent) throw new McpError(ErrorCode.InternalError, `Agent not found: ${this.agentId}`)
|
||||
const workspace = agent.accessible_paths?.[0]
|
||||
if (!workspace) throw new McpError(ErrorCode.InternalError, 'Agent has no workspace path configured')
|
||||
return workspace
|
||||
}
|
||||
|
||||
private async memoryUpdate(args: Record<string, string | undefined>) {
|
||||
const content = args.content
|
||||
if (!content) throw new McpError(ErrorCode.InvalidParams, "'content' is required for update action")
|
||||
|
||||
const workspace = await this.getWorkspacePath()
|
||||
const memoryDir = path.join(workspace, 'memory')
|
||||
const factPath = await resolveFileCI(memoryDir, 'FACT.md')
|
||||
|
||||
await mkdir(memoryDir, { recursive: true })
|
||||
|
||||
// Atomic write via temp file + rename
|
||||
const tmpPath = `${factPath}.${Date.now()}.tmp`
|
||||
await writeFile(tmpPath, content, 'utf-8')
|
||||
await rename(tmpPath, factPath)
|
||||
|
||||
logger.info('Memory FACT.md updated via tool', { agentId: this.agentId, length: content.length })
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Memory updated.' }]
|
||||
}
|
||||
}
|
||||
|
||||
private async memoryAppend(args: Record<string, string | undefined>) {
|
||||
const text = args.text
|
||||
if (!text) throw new McpError(ErrorCode.InvalidParams, "'text' is required for append action")
|
||||
|
||||
const tags: string[] = []
|
||||
const rawTags = (args as Record<string, unknown>).tags
|
||||
if (Array.isArray(rawTags)) {
|
||||
for (const item of rawTags) {
|
||||
if (typeof item === 'string') tags.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
const workspace = await this.getWorkspacePath()
|
||||
const memoryDir = path.join(workspace, 'memory')
|
||||
|
||||
await mkdir(memoryDir, { recursive: true })
|
||||
|
||||
const journalPath = await resolveFileCI(memoryDir, 'JOURNAL.jsonl')
|
||||
|
||||
const entry: JournalEntry = {
|
||||
ts: new Date().toISOString(),
|
||||
tags,
|
||||
text
|
||||
}
|
||||
|
||||
await appendFile(journalPath, JSON.stringify(entry) + '\n', 'utf-8')
|
||||
|
||||
logger.info('Journal entry appended via tool', { agentId: this.agentId, tags })
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Journal entry added at ${entry.ts}.` }]
|
||||
}
|
||||
}
|
||||
|
||||
private async memorySearch(args: Record<string, string | undefined>) {
|
||||
const query = args.query ?? ''
|
||||
const tagFilter = args.tag ?? ''
|
||||
const limit = Math.max(1, parseInt(args.limit ?? '20', 10) || 20)
|
||||
|
||||
const workspace = await this.getWorkspacePath()
|
||||
const memoryDir = path.join(workspace, 'memory')
|
||||
const journalPath = await resolveFileCI(memoryDir, 'JOURNAL.jsonl')
|
||||
|
||||
let fileContent: string
|
||||
try {
|
||||
fileContent = await readFile(journalPath, 'utf-8')
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { content: [{ type: 'text' as const, text: 'No journal entries found.' }] }
|
||||
}
|
||||
throw new Error(`Failed to read journal at ${journalPath}: ${(err as Error).message}`)
|
||||
}
|
||||
|
||||
const queryLower = query.toLowerCase()
|
||||
const tagLower = tagFilter.toLowerCase()
|
||||
const matches: JournalEntry[] = []
|
||||
|
||||
for (const line of fileContent.split('\n')) {
|
||||
if (!line.trim()) continue
|
||||
let entry: JournalEntry
|
||||
try {
|
||||
entry = JSON.parse(line)
|
||||
} catch {
|
||||
logger.warn('Skipping corrupted journal line', { journalPath, line: line.substring(0, 100) })
|
||||
continue
|
||||
}
|
||||
if (tagFilter && !entry.tags?.some((t) => t.toLowerCase() === tagLower)) continue
|
||||
if (query && !entry.text.toLowerCase().includes(queryLower)) continue
|
||||
matches.push(entry)
|
||||
}
|
||||
|
||||
// Return last N entries in reverse-chronological order
|
||||
const result = matches.slice(-limit).reverse()
|
||||
|
||||
if (result.length === 0) {
|
||||
return { content: [{ type: 'text' as const, text: 'No matching journal entries found.' }] }
|
||||
}
|
||||
|
||||
logger.info('Journal search via tool', { agentId: this.agentId, query, tag: tagFilter, resultCount: result.length })
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkspaceMemoryServer
|
||||
@@ -252,4 +252,144 @@ describe('PromptBuilder', () => {
|
||||
expect(result).toContain('<user>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildToolGuidance', () => {
|
||||
it('returns skills, memory, and web sections without claw by default', () => {
|
||||
const result = builder.buildToolGuidance()
|
||||
|
||||
expect(result).toContain('## Skills')
|
||||
expect(result).toContain('mcp__skills__skills')
|
||||
expect(result).toContain('## Workspace Memory')
|
||||
expect(result).toContain('mcp__agent-memory__memory')
|
||||
expect(result).toContain('## Web Search & Browser Strategy')
|
||||
expect(result).toContain('mcp__exa__web_search_exa')
|
||||
expect(result).not.toContain('## CherryClaw Tools')
|
||||
expect(result).not.toContain('mcp__claw__cron')
|
||||
expect(result).not.toContain('mcp__claw__notify')
|
||||
expect(result).not.toContain('mcp__claw__config')
|
||||
})
|
||||
|
||||
it('includes claw section when hasClaw is true', () => {
|
||||
const result = builder.buildToolGuidance({ hasClaw: true })
|
||||
|
||||
expect(result).toContain('## CherryClaw Tools')
|
||||
expect(result).toContain('mcp__claw__cron')
|
||||
expect(result).toContain('mcp__claw__notify')
|
||||
expect(result).toContain('mcp__claw__config')
|
||||
// Skills, memory, and web are still included
|
||||
expect(result).toContain('mcp__skills__skills')
|
||||
expect(result).toContain('mcp__agent-memory__memory')
|
||||
expect(result).toContain('## Web Search & Browser Strategy')
|
||||
})
|
||||
|
||||
it('places claw guidance before skills/memory when present', () => {
|
||||
const result = builder.buildToolGuidance({ hasClaw: true })
|
||||
|
||||
const clawIdx = result.indexOf('## CherryClaw Tools')
|
||||
const skillsIdx = result.indexOf('## Skills')
|
||||
const memoryIdx = result.indexOf('## Workspace Memory')
|
||||
const webIdx = result.indexOf('## Web Search & Browser Strategy')
|
||||
|
||||
expect(clawIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(clawIdx).toBeLessThan(skillsIdx)
|
||||
expect(skillsIdx).toBeLessThan(memoryIdx)
|
||||
expect(memoryIdx).toBeLessThan(webIdx)
|
||||
})
|
||||
|
||||
it('teaches when to act for skills (init/register and patching)', () => {
|
||||
const result = builder.buildToolGuidance()
|
||||
|
||||
expect(result).toMatch(/init.*register|register.*init/)
|
||||
expect(result).toMatch(/edit.*in place|patch|outdated/i)
|
||||
})
|
||||
|
||||
it('teaches when to act for memory (search-before-ask, FACT vs JOURNAL)', () => {
|
||||
const result = builder.buildToolGuidance()
|
||||
|
||||
expect(result).toMatch(/search.*before|before.*ask/i)
|
||||
expect(result).toContain('FACT.md')
|
||||
expect(result).toContain('JOURNAL')
|
||||
expect(result).toMatch(/6 months|durable/i)
|
||||
})
|
||||
|
||||
it('returns the same content soul-mode buildSystemPrompt embeds (with claw)', async () => {
|
||||
setupFiles({})
|
||||
const soulPrompt = await builder.buildSystemPrompt('/workspace')
|
||||
const guidance = builder.buildToolGuidance({ hasClaw: true })
|
||||
|
||||
// The Soul prompt should embed every section the with-claw guidance has.
|
||||
expect(soulPrompt).toContain('## CherryClaw Tools')
|
||||
expect(soulPrompt).toContain('## Skills')
|
||||
expect(soulPrompt).toContain('## Workspace Memory')
|
||||
expect(soulPrompt).toContain('## Web Search & Browser Strategy')
|
||||
// And the guidance string is a contiguous substring of the soul prompt.
|
||||
expect(soulPrompt).toContain(guidance)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildFactsSection', () => {
|
||||
it('returns undefined when no FACT.md exists', async () => {
|
||||
setupFiles({})
|
||||
|
||||
const result = await builder.buildFactsSection('/workspace')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('wraps memory/FACT.md content in a Workspace Knowledge block', async () => {
|
||||
setupFiles({
|
||||
'/workspace/memory/FACT.md': '- Project: cherry-studio\n- Build tool: pnpm + electron-vite'
|
||||
})
|
||||
|
||||
const result = await builder.buildFactsSection('/workspace')
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toContain('## Workspace Knowledge')
|
||||
expect(result).toContain('<facts>')
|
||||
expect(result).toContain('Project: cherry-studio')
|
||||
expect(result).toContain('Build tool: pnpm + electron-vite')
|
||||
expect(result).toContain('</facts>')
|
||||
// The agent should also be told to keep updating FACT.md
|
||||
expect(result).toContain('mcp__agent-memory__memory')
|
||||
expect(result).toContain('action="update"')
|
||||
})
|
||||
|
||||
it('resolves FACT.md case-insensitively', async () => {
|
||||
setupFiles({
|
||||
'/workspace/memory/fact.md': '- lowercase filename'
|
||||
})
|
||||
|
||||
const result = await builder.buildFactsSection('/workspace')
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toContain('lowercase filename')
|
||||
})
|
||||
|
||||
it('returns undefined when FACT.md exists but is empty', async () => {
|
||||
setupFiles({
|
||||
'/workspace/memory/FACT.md': ''
|
||||
})
|
||||
|
||||
const result = await builder.buildFactsSection('/workspace')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not include SOUL.md or USER.md content (those are Soul-only)', async () => {
|
||||
setupFiles({
|
||||
'/workspace/SOUL.md': 'Warm but direct.',
|
||||
'/workspace/user.md': 'Name: V',
|
||||
'/workspace/memory/FACT.md': 'Build tool: pnpm'
|
||||
})
|
||||
|
||||
const result = await builder.buildFactsSection('/workspace')
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toContain('Build tool: pnpm')
|
||||
expect(result).not.toContain('Warm but direct')
|
||||
expect(result).not.toContain('Name: V')
|
||||
expect(result).not.toContain('<soul>')
|
||||
expect(result).not.toContain('<user>')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,26 +40,46 @@ const DEFAULT_BASIC_PROMPT = `You are CherryClaw, a personal assistant running i
|
||||
|
||||
`
|
||||
|
||||
const TOOLS_SECTION = `## CherryClaw Tools
|
||||
const SKILLS_GUIDANCE = `## Skills
|
||||
|
||||
You have exclusive access to these tools for interacting with CherryStudio. Always prefer them over manual alternatives.
|
||||
You can manage Claude skills via the \`mcp__skills__skills\` tool — search the marketplace, install / remove existing skills, and author new ones via the \`init\` and \`register\` actions. Discovery and runtime activation of installed skills is handled automatically by the agent SDK; this tool is just the management surface.
|
||||
|
||||
When to act:
|
||||
- When the user asks for a capability you don't already have, search the marketplace before attempting the task from scratch — there is often an existing skill that fits.
|
||||
- After completing a non-trivial task (5+ tool calls, an iterative fix, a workflow you'd want to repeat), offer to save the approach as a new skill via \`init\` + \`register\`.
|
||||
- If you find an installed skill is outdated, incomplete, or wrong, fix it in place. Get the skill's \`path\` from \`mcp__skills__skills\` action="list" (or use the path returned by \`init\` if you just created it), then use the native Read / Edit tools on the files in that directory. The live symlink picks up file changes immediately, so no separate "patch" call is needed. Don't wait for the user to ask — patch immediately when you notice the issue.`
|
||||
|
||||
const MEMORY_GUIDANCE = `## Workspace Memory
|
||||
|
||||
You have persistent memory in this agent's workspace via the \`mcp__agent-memory__memory\` tool: \`update\` rewrites \`memory/FACT.md\` (durable knowledge), \`append\` adds a timestamped entry to \`memory/JOURNAL.jsonl\` (one-off events), and \`search\` queries the journal.
|
||||
|
||||
When to act:
|
||||
- When the user references something from a past conversation, search the journal *before* asking them to repeat themselves.
|
||||
- When the user corrects you with information that should survive across sessions ("we use X not Y", "the prod URL is Z"), update \`FACT.md\`.
|
||||
- When the user corrects your *approach* or points out a better way to do something (e.g. "use skill-creator instead of writing SKILL.md manually"), update \`FACT.md\` with the lesson immediately so you don't repeat the same mistake in future sessions.
|
||||
- When a tool call fails and you discover a workaround or correct usage pattern (e.g. a file was too large to read in one call so you switched to paginated reads, or an API required a different parameter format), update \`FACT.md\` with the lesson so future sessions avoid the same trial-and-error.
|
||||
- For one-off events, completed tasks, or session notes, append to the journal.
|
||||
- Before writing to \`FACT.md\`, ask: will this still matter in 6 months? If not, append to the journal instead.
|
||||
- Never write to \`memory/FACT.md\` or \`memory/JOURNAL.jsonl\` via direct file tools — always go through the memory tool so writes stay atomic and searchable.`
|
||||
|
||||
const CLAW_GUIDANCE = `## CherryClaw Tools
|
||||
|
||||
You have exclusive access to these tools for interacting with CherryStudio's autonomous features. Always prefer them over manual alternatives.
|
||||
|
||||
| Tool | Purpose | When to use |
|
||||
|---|---|---|
|
||||
| \`mcp__claw__cron\` | Schedule recurring or one-time tasks | Creating reminders, periodic checks, scheduled reports. Never use builtin Cron* tools — they are disabled. |
|
||||
| \`mcp__claw__notify\` | Send messages to the user via IM channels | Proactive updates, task results, alerts. Use when the user is not in the current session. |
|
||||
| \`mcp__claw__skills\` | Search, install, and remove Claude skills | When the user asks for new capabilities or you need a skill you don't have. |
|
||||
| \`mcp__claw__memory\` | Manage JOURNAL.jsonl (append and search) | Log events and search past activity. Never write to JOURNAL.jsonl directly via file tools. |
|
||||
| \`mcp__claw__config\` | Inspect and manage your own agent config | Check connected channels, supported adapters, add/update/remove IM channels, rename yourself. |
|
||||
|
||||
Rules:
|
||||
- These are your primary interface to CherryStudio. Do not attempt workarounds or alternative approaches.
|
||||
- These are your primary interface to CherryStudio's autonomous features. Do not attempt workarounds or alternative approaches.
|
||||
- When creating scheduled tasks, always use \`mcp__claw__cron\`. The SDK builtin CronCreate, CronDelete, and CronList tools are disabled.
|
||||
- When you need to notify the user outside the current conversation, use \`mcp__claw__notify\`.
|
||||
- When adding a WeChat channel, the config tool returns a QR code image. Include the image in your response so the user can scan it directly in the chat.
|
||||
- Use \`config status\` to check which channels are actually connected. If a channel shows \`connected: false\`, use \`config reconnect_channel\` to trigger a fresh QR scan.
|
||||
- Use \`config status\` to check which channels are actually connected. If a channel shows \`connected: false\`, use \`config reconnect_channel\` to trigger a fresh QR scan.`
|
||||
|
||||
## Web Search & Browser Strategy
|
||||
const WEB_TOOLS_GUIDANCE = `## Web Search & Browser Strategy
|
||||
|
||||
You have two complementary web tools: \`mcp__exa__web_search_exa\` for structured search and \`mcp__browser__*\` for page interaction.
|
||||
|
||||
@@ -72,8 +92,23 @@ You have two complementary web tools: \`mcp__exa__web_search_exa\` for structure
|
||||
- Combining search + browse: search with Exa while simultaneously screenshotting a known URL
|
||||
|
||||
**Use \`mcp__browser__screenshot\`** to visually inspect pages (search results, dashboards, verification). It's far more efficient than fetching full page content.
|
||||
**Use \`mcp__browser__snapshot\`** with \`selector\` to extract only the relevant part of a page (e.g., \`selector: "#search"\` for Google results).
|
||||
`
|
||||
**Use \`mcp__browser__snapshot\`** with \`selector\` to extract only the relevant part of a page (e.g., \`selector: "#search"\` for Google results).`
|
||||
|
||||
/**
|
||||
* Compose the tool-strategy guidance for an agent based on which MCP servers
|
||||
* have actually been injected. The skills, memory, and web-tools sections are
|
||||
* always present (those servers are injected for every agent); the claw
|
||||
* section is only included for autonomous (Soul Mode) agents that get the
|
||||
* cron / notify / config tools.
|
||||
*/
|
||||
function composeToolGuidance(opts: { hasClaw: boolean }): string {
|
||||
const parts: string[] = []
|
||||
if (opts.hasClaw) parts.push(CLAW_GUIDANCE)
|
||||
parts.push(SKILLS_GUIDANCE)
|
||||
parts.push(MEMORY_GUIDANCE)
|
||||
parts.push(WEB_TOOLS_GUIDANCE)
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
function memoriesTemplate(workspacePath: string, sections: string): string {
|
||||
return `## Memories
|
||||
@@ -85,22 +120,33 @@ Persistent files in \`${workspacePath}/\` carry your state across sessions. Upda
|
||||
| \`SOUL.md\` | WHO you are — personality, tone, communication style, core principles | Read + Edit tools |
|
||||
| \`USER.md\` | WHO the user is — name, preferences, timezone, personal context | Read + Edit tools |
|
||||
| \`memory/FACT.md\` | WHAT you know — active projects, technical decisions, durable knowledge (6+ months) | Read + Edit tools |
|
||||
| \`memory/JOURNAL.jsonl\` | WHEN things happened — one-time events, session notes (append-only log) | \`mcp__claw__memory\` tool only (actions: append, search) |
|
||||
| \`memory/JOURNAL.jsonl\` | WHEN things happened — one-time events, session notes (append-only log) | \`mcp__agent-memory__memory\` tool only (actions: append, search) |
|
||||
|
||||
Rules:
|
||||
- Each file has an exclusive scope — never duplicate information across files.
|
||||
- \`SOUL.md\`, \`USER.md\`, and \`memory/FACT.md\` are loaded below. Read and edit them directly when updates are needed.
|
||||
- \`memory/JOURNAL.jsonl\` is NOT loaded into context. Use \`mcp__claw__memory\` to append entries or search past events. Never read or write the file directly.
|
||||
- \`memory/JOURNAL.jsonl\` is NOT loaded into context. Use \`mcp__agent-memory__memory\` to append entries or search past events. Never read or write the file directly.
|
||||
- Filenames are case-insensitive.
|
||||
${sections}`
|
||||
}
|
||||
|
||||
/**
|
||||
* PromptBuilder assembles the full system prompt for CherryClaw from workspace files.
|
||||
* PromptBuilder assembles the system prompt for CherryStudio agents.
|
||||
*
|
||||
* Structure: basic prompt (system.md override or default) + tools section + memories section.
|
||||
* Two entry points:
|
||||
*
|
||||
* Memory files layout:
|
||||
* 1. {@link buildSystemPrompt} — full custom prompt for Soul Mode agents that
|
||||
* REPLACES the SDK preset entirely. Includes the basic identity, the full
|
||||
* tool guidance (claw + skills + memory + web), bootstrap instructions when
|
||||
* needed, and the workspace memory files (SOUL.md / USER.md / FACT.md).
|
||||
*
|
||||
* 2. {@link buildToolGuidance} — lightweight tool-strategy suffix for
|
||||
* non-Soul agents. Does not touch workspace files; intended to be APPENDED
|
||||
* to the SDK's `claude_code` preset so the model gets cross-tool strategy
|
||||
* guidance (skills + memory + web) on top of the standard Claude Code
|
||||
* instructions. Returns a synchronous string — no I/O.
|
||||
*
|
||||
* Memory files layout (Soul Mode only):
|
||||
* {workspace}/soul.md — personality, tone, communication style
|
||||
* {workspace}/user.md — user profile, preferences, context
|
||||
* {workspace}/memory/FACT.md — durable project knowledge, technical decisions
|
||||
@@ -117,8 +163,8 @@ export class PromptBuilder {
|
||||
const basicPrompt = systemPath ? await this.readCachedFile(systemPath) : undefined
|
||||
parts.push(basicPrompt ?? DEFAULT_BASIC_PROMPT)
|
||||
|
||||
// Tools section (always included)
|
||||
parts.push(TOOLS_SECTION)
|
||||
// Tool guidance — Soul Mode gets the full set including claw (cron / notify / config)
|
||||
parts.push(composeToolGuidance({ hasClaw: true }))
|
||||
|
||||
// Bootstrap detection: inject bootstrap instructions if not completed
|
||||
const needsBootstrap = await this.shouldRunBootstrap(workspacePath, config)
|
||||
@@ -136,6 +182,50 @@ export class PromptBuilder {
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the cross-tool strategy guidance string for a non-Soul agent. The
|
||||
* returned text is meant to be APPENDED to the Claude Code SDK preset so
|
||||
* the model gets explicit "when to use which tool" guidance on top of the
|
||||
* SDK's built-in instructions. The skills + memory + web sections are
|
||||
* always included (those MCP servers are injected for every agent); the
|
||||
* claw section is excluded by default (non-Soul agents do not get cron /
|
||||
* notify / config).
|
||||
*/
|
||||
buildToolGuidance(opts: { hasClaw?: boolean } = {}): string {
|
||||
return composeToolGuidance({ hasClaw: opts.hasClaw ?? false })
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a "## Workspace Knowledge" section for non-Soul agents that loads
|
||||
* just the workspace's `memory/FACT.md` content. This is the recall side of
|
||||
* the cross-session learning loop — agents write durable knowledge to
|
||||
* FACT.md via \`mcp__agent-memory__memory\` action="update", and this method
|
||||
* loads it back into the system prompt at the start of the next session so
|
||||
* the agent remembers what it learned (e.g. parameter shapes that previously
|
||||
* failed, project conventions, user corrections).
|
||||
*
|
||||
* Distinct from {@link buildSystemPrompt}'s memories section which is Soul
|
||||
* Mode only and also includes the SOUL.md / USER.md persona files. Returns
|
||||
* undefined when no FACT.md exists, so callers can omit the section
|
||||
* entirely rather than emitting an empty wrapper.
|
||||
*/
|
||||
async buildFactsSection(workspacePath: string): Promise<string | undefined> {
|
||||
const memoryDir = path.join(workspacePath, 'memory')
|
||||
const factPath = await resolveFile(memoryDir, 'FACT.md')
|
||||
if (!factPath) return undefined
|
||||
|
||||
const content = await this.readCachedFile(factPath)
|
||||
if (!content) return undefined
|
||||
|
||||
return `## Workspace Knowledge
|
||||
|
||||
These are durable facts and lessons accumulated across past sessions in this workspace. Trust them as ground truth unless you have direct evidence they're wrong — in which case update \`memory/FACT.md\` via \`mcp__agent-memory__memory\` action="update" so the next session also benefits.
|
||||
|
||||
<facts>
|
||||
${content}
|
||||
</facts>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether bootstrap should run.
|
||||
* - If `bootstrap_completed` is explicitly true, skip.
|
||||
|
||||
@@ -25,6 +25,8 @@ import { isWin } from '@main/constant'
|
||||
import AssistantServer from '@main/mcpServers/assistant'
|
||||
import BrowserServer from '@main/mcpServers/browser/server'
|
||||
import ClawServer from '@main/mcpServers/claw'
|
||||
import SkillsServer from '@main/mcpServers/skills'
|
||||
import WorkspaceMemoryServer from '@main/mcpServers/workspaceMemory'
|
||||
import { configManager } from '@main/services/ConfigManager'
|
||||
import {
|
||||
getNodeProxyConfigFromEnvironment,
|
||||
@@ -416,6 +418,21 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
| undefined
|
||||
const isAssistant = builtinRole === 'assistant'
|
||||
|
||||
// For non-Soul, non-Assistant agents we still want the model to know how
|
||||
// to use the skills + memory MCP servers we inject for everyone, plus the
|
||||
// shared web tool strategy. This is a lightweight strategy suffix that
|
||||
// sits on top of the SDK's `claude_code` preset rather than replacing it.
|
||||
// Soul agents already get the full guidance via `soulSystemPrompt`, and
|
||||
// Cherry Assistant has its own specialized prompt path.
|
||||
const nonSoulToolGuidance = !soulEnabled && !isAssistant ? promptBuilder.buildToolGuidance() : ''
|
||||
|
||||
// Recall side of the cross-session learning loop for non-Soul agents:
|
||||
// load `memory/FACT.md` (written via the memory tool in previous sessions)
|
||||
// back into the system prompt so the agent remembers what it learned.
|
||||
// Soul agents already get this via `soulSystemPrompt`'s memories section.
|
||||
const nonSoulFactsRecall =
|
||||
!soulEnabled && !isAssistant && cwd ? await promptBuilder.buildFactsSection(cwd) : undefined
|
||||
|
||||
// Provision built-in agent workspace (copy skills/plugins to working directory)
|
||||
if (builtinRole && cwd && !isProvisioned(cwd)) {
|
||||
const agentConfig = await provisionBuiltinAgent(cwd, builtinRole)
|
||||
@@ -485,17 +502,13 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
? assistantSystemPrompt
|
||||
: soulSystemPrompt
|
||||
? `${soulSystemPrompt}${channelSecurityBlock}\n\n${getLanguageInstruction()}`
|
||||
: session.instructions
|
||||
? {
|
||||
type: 'preset',
|
||||
preset: 'claude_code',
|
||||
append: `${session.instructions}${channelSecurityBlock}\n\n${getLanguageInstruction()}`
|
||||
}
|
||||
: {
|
||||
type: 'preset',
|
||||
preset: 'claude_code',
|
||||
append: `${channelSecurityBlock}\n\n${getLanguageInstruction()}`
|
||||
},
|
||||
: {
|
||||
type: 'preset',
|
||||
preset: 'claude_code',
|
||||
append:
|
||||
[nonSoulToolGuidance, nonSoulFactsRecall, session.instructions].filter(Boolean).join('\n\n') +
|
||||
`${channelSecurityBlock}\n\n${getLanguageInstruction()}`
|
||||
},
|
||||
// Built-in agents skip CLAUDE.md loading to save tokens
|
||||
settingSources: builtinRole ? [] : ['project', 'local'],
|
||||
includePartialMessages: true,
|
||||
@@ -552,13 +565,53 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
url: 'https://mcp.exa.ai/mcp'
|
||||
}
|
||||
|
||||
// Inject skills MCP for all agents — managing Claude skills (search / install
|
||||
// / list / remove / init / register) is a generally useful capability and is
|
||||
// not coupled to Soul Mode's autonomous-agent semantics.
|
||||
const skillsServer = new SkillsServer(session.agent_id)
|
||||
options.mcpServers.skills = { type: 'sdk', name: 'skills', instance: skillsServer.mcpServer }
|
||||
// Auto-approve via Cherry Studio's own permission gate. The SDK whitelist
|
||||
// (`options.allowedTools`) takes glob patterns, but `canUseTool` checks
|
||||
// `autoAllowTools` with exact string matching, so we have to add the full
|
||||
// tool names there too — otherwise non-Soul agents (which do not run in
|
||||
// bypassPermissions mode) get an approval prompt for every call.
|
||||
autoAllowTools.add('mcp__skills__skills')
|
||||
if (Array.isArray(options.allowedTools) && options.allowedTools.length > 0) {
|
||||
if (!options.allowedTools.includes('mcp__skills__*')) {
|
||||
options.allowedTools = [...options.allowedTools, 'mcp__skills__*']
|
||||
}
|
||||
}
|
||||
|
||||
// Inject agent workspace memory MCP for all agents — cross-session FACT.md /
|
||||
// JOURNAL.jsonl in the agent's workspace. Distinct from the user-opt-in
|
||||
// built-in `memory-server` (knowledge graph). Any agent with a stable
|
||||
// workspace benefits from this.
|
||||
const workspaceMemoryServer = new WorkspaceMemoryServer(session.agent_id)
|
||||
options.mcpServers['agent-memory'] = {
|
||||
type: 'sdk',
|
||||
name: 'agent-memory',
|
||||
instance: workspaceMemoryServer.mcpServer
|
||||
}
|
||||
autoAllowTools.add('mcp__agent-memory__memory')
|
||||
if (Array.isArray(options.allowedTools) && options.allowedTools.length > 0) {
|
||||
if (!options.allowedTools.includes('mcp__agent-memory__*')) {
|
||||
options.allowedTools = [...options.allowedTools, 'mcp__agent-memory__*']
|
||||
}
|
||||
}
|
||||
|
||||
if (soulEnabled) {
|
||||
// Find the channel that owns this session (if any) for context-aware cron defaults
|
||||
const sourceChannelId = await this.resolveSourceChannel(session.agent_id, session.id)
|
||||
const clawServer = new ClawServer(session.agent_id, sourceChannelId)
|
||||
options.mcpServers.claw = { type: 'sdk', name: 'claw', instance: clawServer.mcpServer }
|
||||
|
||||
// Ensure claw MCP tools are in allowed_tools whitelist
|
||||
// Auto-approve claw MCP tools at both layers (see skills/memory above
|
||||
// for the SDK-glob vs canUseTool-exact-match rationale). Soul agents
|
||||
// typically run in bypassPermissions, so this is defense in depth, but
|
||||
// it lets claw also work for any future non-bypass Soul session.
|
||||
autoAllowTools.add('mcp__claw__cron')
|
||||
autoAllowTools.add('mcp__claw__notify')
|
||||
autoAllowTools.add('mcp__claw__config')
|
||||
if (Array.isArray(options.allowedTools) && options.allowedTools.length > 0) {
|
||||
if (!options.allowedTools.includes('mcp__claw__*')) {
|
||||
options.allowedTools = [...options.allowedTools, 'mcp__claw__*']
|
||||
@@ -576,7 +629,10 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
const assistantServer = new AssistantServer()
|
||||
options.mcpServers.assistant = { type: 'sdk', name: 'assistant', instance: assistantServer.mcpServer }
|
||||
|
||||
// Auto-approve assistant MCP tools
|
||||
// Auto-approve assistant MCP tools at both layers (see skills/memory
|
||||
// above for the SDK-glob vs canUseTool-exact-match rationale).
|
||||
autoAllowTools.add('mcp__assistant__navigate')
|
||||
autoAllowTools.add('mcp__assistant__diagnose')
|
||||
if (Array.isArray(options.allowedTools) && options.allowedTools.length > 0) {
|
||||
if (!options.allowedTools.includes('mcp__assistant__*')) {
|
||||
options.allowedTools = [...options.allowedTools, 'mcp__assistant__*']
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as crypto from 'node:crypto'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { pathExists } from '@main/utils/file'
|
||||
@@ -17,8 +18,16 @@ const logger = loggerService.withContext('SkillInstaller')
|
||||
export class SkillInstaller {
|
||||
/**
|
||||
* Install a skill folder to the destination path with backup-restore safety.
|
||||
*
|
||||
* If sourceDir and destPath resolve to the same location, the files are
|
||||
* already in place (in-place registration flow) and no copy is performed.
|
||||
*/
|
||||
async install(sourceDir: string, destPath: string): Promise<void> {
|
||||
if (path.resolve(sourceDir) === path.resolve(destPath)) {
|
||||
logger.debug('Source equals destination, skipping copy', { destPath })
|
||||
return
|
||||
}
|
||||
|
||||
const backupPath = `${destPath}.bak`
|
||||
let hasBackup = false
|
||||
|
||||
|
||||
@@ -122,6 +122,21 @@ export class SkillService {
|
||||
await this.uninstall(skill.id)
|
||||
}
|
||||
|
||||
async getByFolderName(name: string): Promise<InstalledSkill | null> {
|
||||
const folderName = this.sanitizeFolderName(name)
|
||||
return this.repository.getByFolderName(folderName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the absolute path a skill with the given name would live at under
|
||||
* the global Skills storage root. The name is sanitized using the same rules
|
||||
* as installSkillDir, so callers can pre-create the directory and then pass
|
||||
* the path to installFromDirectory for in-place registration.
|
||||
*/
|
||||
getSkillDirectory(name: string): string {
|
||||
return this.getSkillStoragePath(this.sanitizeFolderName(name))
|
||||
}
|
||||
|
||||
async uninstall(skillId: string): Promise<void> {
|
||||
const skill = await this.repository.getById(skillId)
|
||||
if (!skill) {
|
||||
@@ -244,6 +259,7 @@ export class SkillService {
|
||||
folderName,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +275,14 @@ export class SkillService {
|
||||
await fs.promises.unlink(linkPath)
|
||||
logger.info('Skill unlinked', { folderName })
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error('Failed to unlink skill', {
|
||||
folderName,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
throw error
|
||||
}
|
||||
// Link doesn't exist, nothing to do
|
||||
}
|
||||
}
|
||||
@@ -368,7 +391,15 @@ export class SkillService {
|
||||
*/
|
||||
private async installSkillDir(skillDir: string, source: string, sourceUrl: string | null): Promise<InstalledSkill> {
|
||||
const metadata = await parseSkillMetadata(skillDir, path.basename(skillDir), 'skills')
|
||||
const folderName = this.sanitizeFolderName(metadata.filename)
|
||||
|
||||
// In-place registration: when skillDir already lives directly under the global
|
||||
// Skills storage root, preserve its existing basename as folderName so the
|
||||
// destination resolves to the same path and SkillInstaller short-circuits the
|
||||
// copy. This avoids sanitize drift between caller-chosen names and metadata.
|
||||
const skillsRoot = path.resolve(getDataPath('Skills'))
|
||||
const isInPlace = path.resolve(path.dirname(skillDir)) === skillsRoot
|
||||
// INVARIANT: isInPlace assumes basename was already sanitized by getSkillDirectory()
|
||||
const folderName = isInPlace ? path.basename(skillDir) : this.sanitizeFolderName(metadata.filename)
|
||||
|
||||
// Check for existing skill with same folder name
|
||||
const existing = await this.repository.getByFolderName(folderName)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockPathExists = vi.fn()
|
||||
const mockCopyDirectoryRecursive = vi.fn()
|
||||
const mockDeleteDirectoryRecursive = vi.fn()
|
||||
const mockFsRename = vi.fn()
|
||||
|
||||
vi.mock('@main/utils/file', () => ({
|
||||
pathExists: (...args: unknown[]) => mockPathExists(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@main/utils/fileOperations', () => ({
|
||||
copyDirectoryRecursive: (...args: unknown[]) => mockCopyDirectoryRecursive(...args),
|
||||
deleteDirectoryRecursive: (...args: unknown[]) => mockDeleteDirectoryRecursive(...args)
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
promises: {
|
||||
rename: (...args: unknown[]) => mockFsRename(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@main/utils/markdownParser', () => ({
|
||||
findSkillMdPath: vi.fn()
|
||||
}))
|
||||
|
||||
const { SkillInstaller } = await import('../SkillInstaller')
|
||||
|
||||
describe('SkillInstaller', () => {
|
||||
let installer: InstanceType<typeof SkillInstaller>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
installer = new SkillInstaller()
|
||||
})
|
||||
|
||||
describe('install', () => {
|
||||
it('should skip copy when source and destination resolve to the same path', async () => {
|
||||
await installer.install('/global-skills/my-skill', '/global-skills/my-skill')
|
||||
|
||||
expect(mockPathExists).not.toHaveBeenCalled()
|
||||
expect(mockCopyDirectoryRecursive).not.toHaveBeenCalled()
|
||||
expect(mockFsRename).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should copy when source and destination are different', async () => {
|
||||
mockPathExists.mockResolvedValue(false)
|
||||
mockCopyDirectoryRecursive.mockResolvedValue(undefined)
|
||||
|
||||
await installer.install('/tmp/my-skill', '/global-skills/my-skill')
|
||||
|
||||
expect(mockCopyDirectoryRecursive).toHaveBeenCalledWith('/tmp/my-skill', '/global-skills/my-skill')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { AiProvider } from '../aiCore'
|
||||
import { getRotatedApiKey } from '../services/ApiService'
|
||||
|
||||
const logger = loggerService.withContext('DimensionsInput')
|
||||
|
||||
@@ -48,7 +49,11 @@ const InputEmbeddingDimension = ({
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const aiProvider = new AiProvider(provider)
|
||||
const providerWithRotatedKey = {
|
||||
...provider,
|
||||
apiKey: getRotatedApiKey(provider)
|
||||
}
|
||||
const aiProvider = new AiProvider(providerWithRotatedKey)
|
||||
const dimension = await aiProvider.getEmbeddingDimensions(model)
|
||||
// for controlled input
|
||||
if (ref?.current) {
|
||||
|
||||
@@ -87,7 +87,11 @@ vi.mock('@renderer/aiCore', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/hooks/useProvider', () => ({
|
||||
useProvider: () => ({ provider: { id: 'test-provider', name: 'Test Provider' } })
|
||||
useProvider: () => ({ provider: { id: 'test-provider', name: 'Test Provider', apiKey: 'test-key' } })
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/services/ApiService', () => ({
|
||||
getRotatedApiKey: (provider: any) => provider.apiKey || ''
|
||||
}))
|
||||
|
||||
// mock i18n
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
|
||||
import MarqueeText from '@renderer/components/MarqueeText'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import AgentSettingsPopup from '@renderer/pages/settings/AgentSettings/AgentSettingsPopup'
|
||||
import { AgentLabel, isSoulModeEnabled } from '@renderer/pages/settings/AgentSettings/shared'
|
||||
import { AgentLabel } from '@renderer/pages/settings/AgentSettings/shared'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import type { AgentEntity } from '@renderer/types'
|
||||
import { cn } from '@renderer/utils'
|
||||
@@ -81,7 +81,6 @@ const AgentItem = ({ agent, isActive, onDelete, onPress }: AgentItemProps) => {
|
||||
<MarqueeText className="flex min-w-0 flex-1">
|
||||
<AgentLabel agent={agent} hideIcon={assistantIconType === 'none'} />
|
||||
</MarqueeText>
|
||||
{isSoulModeEnabled(agent.configuration) && <SoulTag>SOUL</SoulTag>}
|
||||
{(isActive || isHovered) && (
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
@@ -143,16 +142,6 @@ export const BotIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ ...pro
|
||||
)
|
||||
}
|
||||
|
||||
export const SoulTag: React.FC<React.HTMLAttributes<HTMLSpanElement>> = ({ className, ...props }) => (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-md bg-purple-500/15 px-1.5 py-0.5 font-medium text-[10px] text-purple-600 leading-none dark:text-purple-400',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export const SessionCount: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
|
||||
<div
|
||||
className={cn('flex flex-row items-center justify-center rounded-full text-(--color-text) text-xs', className)}
|
||||
|
||||
@@ -714,7 +714,7 @@ export function hasApiKey(provider: Provider) {
|
||||
* Get rotated API key for providers that support multiple keys
|
||||
* Returns empty string for providers that don't require API keys
|
||||
*/
|
||||
function getRotatedApiKey(provider: Provider): string {
|
||||
export function getRotatedApiKey(provider: Provider): string {
|
||||
// Handle providers that don't require API keys
|
||||
if (!provider.apiKey || provider.apiKey.trim() === '') {
|
||||
return ''
|
||||
|
||||
Reference in New Issue
Block a user