fix(core): skills live in <cwd>/.volcanic/skills, next to the rest of the workspace config

This commit is contained in:
EugeneTes
2026-10-10 14:16:20 +00:00
parent c3095c69ca
commit dccdf3a8ed
7 changed files with 13 additions and 13 deletions

View File

@@ -79,7 +79,7 @@ Read `docs/architecture.md` first. Key points:
messages when context is built.
- **System prompt** pulls project context from the working directory: `AGENTS.md`, or `CLAUDE.md` if there is no
`AGENTS.md` (never both), plus `PERSONA.md` and `MEMORY.md`. File names match case-insensitively. `SkillCatalog`
lists the cwd's Agent Skills (`.claude/skills/<dir>/SKILL.md`, frontmatter `name`/`description`) in a `<skills>`
lists the cwd's Agent Skills (`.volcanic/skills/<dir>/SKILL.md`, frontmatter `name`/`description`) in a `<skills>`
section on every run; the model reads a SKILL.md with `read`. Only the cwd, so chats never see each other's skills.
The Claude Code provider keeps the CLI's own skills off (`--setting-sources ""`, `--disable-slash-commands`).
- **Extension discovery**: host code, then `<cwd>/.volcanic/settings.json` / `.volcanic/extensions/*.dll`, then the same

View File

@@ -105,7 +105,7 @@ event model; slash commands; provider registration; API key resolution; built-in
Beyond pi: the working directory may also hold `PERSONA.md` (the agent's personality) and `MEMORY.md` (memories
the agent maintains itself with `edit`/`write`); both are appended to the system prompt when present. File names
match case-insensitively. Agent Skills (Anthropic's `SKILL.md` format) in `<cwd>/.claude/skills/<name>/SKILL.md` are
match case-insensitively. Agent Skills (Anthropic's `SKILL.md` format) in `<cwd>/.volcanic/skills/<name>/SKILL.md` are
listed by name and description in the system prompt on every run; the model reads a skill's file when it needs it.
Not ported (by design): the terminal UI, themes, keybindings, renderers, prompt templates,

View File

@@ -146,7 +146,7 @@ The compose file binds them to directories under `/media/disk1/containers/volcan
| Volume | Path | Contents |
|---|---|---|
| `workspaces` | `/data/workspaces` | One directory per chat (`/data/workspaces/<chatId>`): `AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`, skills (`.claude/skills/<name>/SKILL.md`), `inbox/` (received files), `browser/` (screenshots, downloads), project-level `.volcanic/extensions/`, the Google Calendar token (`.volcanic/google-calendar/token.json`), scheduled jobs (`.volcanic/scheduler/jobs.json`), A2A callers and tasks, remote agents and watches (`.volcanic/a2a/`, [a2a.md](a2a.md#files), [a2a.md](a2a.md#client)) |
| `workspaces` | `/data/workspaces` | One directory per chat (`/data/workspaces/<chatId>`): `AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`, skills (`.volcanic/skills/<name>/SKILL.md`), `inbox/` (received files), `browser/` (screenshots, downloads), project-level `.volcanic/extensions/`, the Google Calendar token (`.volcanic/google-calendar/token.json`), scheduled jobs (`.volcanic/scheduler/jobs.json`), A2A callers and tasks, remote agents and watches (`.volcanic/a2a/`, [a2a.md](a2a.md#files), [a2a.md](a2a.md#client)) |
| `agent` | `/data/agent` | Sessions (`sessions/telegram-<chatId>/`), `settings.json`, `extensions/*.dll`, chat time zones (`telegram/chat-settings.json`); a `scheduler/jobs.json` from before jobs were per chat is moved into the workspaces on start |
| `claude` | `/data/claude` | The Claude login (credentials and onboarding state) |

View File

@@ -31,7 +31,7 @@ settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section
| `TELEGRAM_BOT_TOKEN` / `Telegram:BotToken` | — | Required |
| `TELEGRAM_ALLOWED_USER_IDS` / `Telegram:AllowedUserIds` | — | Required, comma separated. The bot refuses to start without it |
| `ADMIN_USER_ID` / `Telegram:AdminUserId` | — (off) | The Telegram user id that may use `/impersonate` (see [Impersonation](#impersonation)). Need not be on the allow-list. Must be positive |
| `Telegram:WorkspaceRoot` | `~/.volcanic/telegram-workspaces` | Chat `<id>` works in `<root>/<id>`; put `AGENTS.md`, `PERSONA.md`, `MEMORY.md` and `.claude/skills/` there |
| `Telegram:WorkspaceRoot` | `~/.volcanic/telegram-workspaces` | Chat `<id>` works in `<root>/<id>`; put `AGENTS.md`, `PERSONA.md`, `MEMORY.md` and `.volcanic/skills/` there |
| `Telegram:Model` | `claude-code/claude-opus-5-5` | Any `provider/model` the Claude Code provider serves |
| `Telegram:EditInterval` | `00:00:01.5` | Minimum time between edits of the "Working..." status message |
| `Telegram:InputDebounce` | `00:00:00.6` | How long the bot waits after a message for more before sending them to the agent as one request. `0` disables the wait; messages still being read together (e.g. a photo still downloading and a text after it) are still sent as one request. Must not be negative |
@@ -182,7 +182,7 @@ chat's workspace and delivers only to that chat.
### Skills
Each chat can have its own [Agent Skills](https://docs.claude.com/en/docs/agents-and-tools/agent-skills/overview) in its
workspace: `<workspace>/.claude/skills/<name>/SKILL.md`, a YAML frontmatter with `name` and `description` followed by
workspace: `<workspace>/.volcanic/skills/<name>/SKILL.md`, a YAML frontmatter with `name` and `description` followed by
the instructions, with scripts or reference files next to it if needed. The system prompt lists every skill's name,
description and path, re-read on every run, so a skill the agent (or you) just wrote is used from the next message
on, no `/new` needed. The model reads the full SKILL.md with `read` when a task matches. Skills without a

View File

@@ -6,7 +6,7 @@ namespace Volcanic.AgenticCore.Features.SystemPrompt;
public sealed record Skill(string Name, string Description, string Path);
/// <summary>
/// Agent Skills (Anthropic's format) from the working directory only: <c>&lt;cwd&gt;/.claude/skills/&lt;dir&gt;/SKILL.md</c>,
/// Agent Skills (Anthropic's format) from the working directory only: <c>&lt;cwd&gt;/.volcanic/skills/&lt;dir&gt;/SKILL.md</c>,
/// a YAML frontmatter with <c>name</c> and <c>description</c>, then the instructions. Scanned on every call, so a skill
/// written by the agent shows up in the next run's system prompt. Skills without a description are skipped; the name
/// defaults to the directory name. The model only sees the list and reads a SKILL.md itself (progressive disclosure).
@@ -17,7 +17,7 @@ public static class SkillCatalog
public const int MaxDescriptionLength = 1024;
private const int MaxFrontmatterBytes = 16 * 1024;
public static string Directory(string cwd) => System.IO.Path.Combine(cwd, ".claude", "skills");
public static string Directory(string cwd) => System.IO.Path.Combine(cwd, ".volcanic", "skills");
public static IReadOnlyList<Skill> Load(string cwd)
{

View File

@@ -7,7 +7,7 @@ public sealed class SkillsChatTests
{
private static void WriteSkill(string workspace, string dir, string name, string description)
{
var path = Path.Combine(workspace, ".claude", "skills", dir);
var path = Path.Combine(workspace, ".volcanic", "skills", dir);
Directory.CreateDirectory(path);
File.WriteAllText(Path.Combine(path, "SKILL.md"), $"---\nname: {name}\ndescription: {description}\n---\nInstructions");
}
@@ -17,13 +17,13 @@ public sealed class SkillsChatTests
{
await using var host = new ChatHost();
await host.SendAsync("/skills");
Assert.Equal("No skills yet. Ask me to write one, or put it in .claude/skills/<name>/SKILL.md.", host.LastReply());
Assert.Equal("No skills yet. Ask me to write one, or put it in .volcanic/skills/<name>/SKILL.md.", host.LastReply());
WriteSkill(host.WorkspaceOf(), "trips", "trip-planner", "Plans trips");
WriteSkill(host.WorkspaceOf(2002), "secret", "other-chat", "Belongs to another chat");
await host.SendAsync("/skills");
Assert.Equal("Skills (.claude/skills/<name>/SKILL.md)\n• trip-planner – Plans trips", host.LastReply());
Assert.Equal("Skills (.volcanic/skills/<name>/SKILL.md)\n• trip-planner – Plans trips", host.LastReply());
}
[Fact]
@@ -42,7 +42,7 @@ public sealed class SkillsChatTests
var prompt = host.Faux.Requests.Last().SystemPrompt!;
Assert.Contains("<name>trip-planner</name>", prompt);
Assert.Contains(Path.Combine(host.WorkspaceOf(), ".claude", "skills", "trips", "SKILL.md"), prompt);
Assert.Contains(Path.Combine(host.WorkspaceOf(), ".volcanic", "skills", "trips", "SKILL.md"), prompt);
Assert.DoesNotContain("other-chat", prompt);
}
}

View File

@@ -100,7 +100,7 @@ public class SkillCatalogTests
public void Lists_workspace_skills_and_picks_up_new_ones_on_the_next_render()
{
var dir = Path.Combine(Path.GetTempPath(), "volcanic-prompt", Guid.NewGuid().ToString("N"));
var skills = Path.Combine(dir, ".claude", "skills");
var skills = Path.Combine(dir, ".volcanic", "skills");
Directory.CreateDirectory(Path.Combine(skills, "pdf"));
Directory.CreateDirectory(Path.Combine(skills, "no-description"));
Directory.CreateDirectory(Path.Combine(skills, "folded"));
@@ -141,7 +141,7 @@ public class SkillCatalogTests
{
Assert.Empty(SkillCatalog.Load(dir));
var section = SkillCatalog.Render(dir, []);
Assert.Contains(Path.Combine(dir, ".claude", "skills"), section);
Assert.Contains(Path.Combine(dir, ".volcanic", "skills"), section);
Assert.EndsWith("No skills yet.", section);
}
finally { Directory.Delete(dir, true); }