Skip to content

Commit b280d82

Browse files
committed
feat: Improve skills
1 parent 52bc7d2 commit b280d82

File tree

13 files changed

+532
-231
lines changed

13 files changed

+532
-231
lines changed

.agents/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ magec/
174174
| | **Backends** | CRUD: `/backends`, `/backends/{id}` |
175175
| | **Memory** | CRUD: `/memory`, `/memory/{id}`, `/memory/types`, `/memory/{id}/health` |
176176
| | **MCP Servers** | CRUD: `/mcps`, `/mcps/{id}` |
177-
| | **Skills** | CRUD: `/skills`, `/skills/{id}` + references: `/skills/{id}/references`, `/skills/{id}/references/{filename}` |
177+
| | **Skills** | CRUD: `/skills`, `/skills/{id}` + references: `/skills/{id}/references`, `/skills/{id}/references/{filename}` + package: `/skills/{id}/package` |
178178
| | **Agents** | CRUD: `/agents`, `/agents/{id}`, `/agents/{id}/mcps`, `/agents/{id}/mcps/{mcpId}` |
179179
| | **Clients** | CRUD: `/clients`, `/clients/{id}`, `/clients/types`, `/clients/{id}/regenerate-token` |
180180
| | **Commands** | CRUD: `/commands`, `/commands/{id}` |

.agents/TODO.md

Lines changed: 6 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,8 @@
11
# Magec - TODO
22

3-
## IN PROGRESS: Client Config — DefaultAgent + ThreadHistoryLimit
3+
## ~~Client Config — DefaultAgent + ThreadHistoryLimit~~
44

5-
### Goal
6-
Two new fields in Discord and Slack client config:
7-
1. **`defaultAgent`** — the agent that starts active on boot, persisted to store when user runs `!agent <id>`
8-
2. **`threadHistoryLimit`** — number of previous thread messages passed as context to the agent (1–100 for Discord, 1–1000 for Slack)
9-
10-
Telegram is excluded from `threadHistoryLimit` because it accumulates all messages via ADK session directly (no mention filter). Telegram does get `defaultAgent`.
11-
12-
### Current state of active agent (before this change)
13-
- Stored as `map[channelID]agentID` (Discord/Slack) or `map[chatID]agentID` (Telegram) in memory only
14-
- Lost on restart — fallback is always `clientDef.AllowedAgents[0]`
15-
- `!agent <id>` only updates the in-memory map, does NOT persist
16-
17-
### Design decisions
18-
- `defaultAgent` is **global per client** (not per channel) — whoever configures the client knows which model/agent it will use
19-
- `!agent <id>` does two things: updates in-memory map AND persists `defaultAgent` to the store via the store's update mechanism
20-
- `getActiveAgentID` fallback order: in-memory map → `defaultAgent` from config → `allowedAgents[0]`
21-
- `threadHistoryLimit` replaces the hardcoded `50` in `fetchThreadContext`; defaults to `50` if not set
22-
23-
### Files to modify
24-
25-
**`server/store/types.go`**:
26-
- Add `DefaultAgent string` and `ThreadHistoryLimit int` to `DiscordClientConfig` and `SlackClientConfig`
27-
- Add `DefaultAgent string` to `TelegramClientConfig`
28-
29-
**`server/clients/discord/spec.go`**:
30-
- Add `defaultAgent` (string, description: "Default agent ID to use on startup") to JSON Schema
31-
- Add `threadHistoryLimit` (integer, min: 1, max: 100, description: "Thread history messages passed to the agent as context") to JSON Schema
32-
33-
**`server/clients/slack/spec.go`**:
34-
- Same as Discord but max: 1000
35-
36-
**`server/clients/telegram/spec.go`** (if exists):
37-
- Add `defaultAgent` only
38-
39-
**`server/clients/discord/bot.go`**:
40-
- `getActiveAgentID`: fallback chain → in-memory map → `c.clientDef.Config.Discord.DefaultAgent``allowedAgents[0]`
41-
- `setActiveAgentID`: keep updating in-memory map + call store update to persist `DefaultAgent`
42-
- `fetchThreadContext`: replace literal `50` with `c.clientDef.Config.Discord.ThreadHistoryLimit` (with fallback to 50 if 0)
43-
44-
**`server/clients/slack/bot.go`**:
45-
- Same pattern as Discord
46-
47-
**`server/clients/telegram/bot.go`**:
48-
- `getActiveAgentID`: fallback chain → in-memory map → `c.clientDef.Config.Telegram.DefaultAgent``allowedAgents[0]`
49-
- `setActiveAgentID`: persist `DefaultAgent` to store
50-
51-
### How to persist DefaultAgent to store
52-
The store exposes `UpdateClient(def ClientDefinition) error` (or equivalent). After `!agent <id>`:
53-
1. Copy `c.clientDef`
54-
2. Set `clientDef.Config.Discord.DefaultAgent = agentID` (or Slack/Telegram)
55-
3. Call store update
56-
The store's `persist()` writes to disk automatically.
57-
58-
Check exact store method signature in `server/store/store.go` before implementing.
59-
60-
### Frontend (admin-ui)
61-
- Discord client form: add "Default agent" text field + "Thread history messages" number input (1–100)
62-
- Slack client form: same but max 1000
63-
- Telegram client form: add "Default agent" text field only
64-
- Labels: "Default agent" and "Thread history messages"
65-
- Tooltip for thread history: "Number of previous thread messages passed to the agent as context. Use lower values for smaller models."
5+
Implemented. `defaultAgent` persisted to store on `!agent <id>` with fallback chain (in-memory → defaultAgent → allowedAgents[0]). `threadHistoryLimit` replaces hardcoded 50 in Discord/Slack `fetchThreadContext`. Shared schema helpers in `server/clients/provider.go`.
666

677
---
688

@@ -606,68 +546,15 @@ If a single person uses Discord AND Telegram, they'd have two `userID`s and two
606546

607547
## Low Priority
608548

609-
### Skill Card View Formatter
549+
### ~~Skill Card View Formatter~~
610550

611-
**Problem**: Skills follow the [Agent Skills Specification](https://agentskills.io/specification) with YAML frontmatter (`name`, `description`, `license`, `compatibility`, `metadata`) followed by a markdown body. Some skills are non-canonical (no frontmatter, arbitrary markdown) and still valid. Rendering the raw SKILL.md in a card looks ugly — `---` delimiters, long markdown bodies, and code blocks don't belong in a card preview.
612-
613-
**Solution**: Parse SKILL.md frontmatter and render structured card data instead of raw markdown.
614-
615-
**Card layout**:
616-
```
617-
┌─────────────────────────────────────┐
618-
│ 🔧 skill-name │
619-
│ │
620-
│ Description from frontmatter, │
621-
│ truncated to 2-3 lines... │
622-
│ │
623-
│ 📁 N files · 🐍 scripts · License │
624-
└─────────────────────────────────────┘
625-
```
626-
627-
- **Title**: `name` from frontmatter
628-
- **Description**: `description` from frontmatter (truncated, max ~200 chars displayed)
629-
- **Footer badges**: file count, content indicators (has `scripts/`, `references/`, `assets/`), license if present
630-
- **Never render** the markdown body in the card
631-
632-
**Fallback for non-canonical skills** (no valid YAML frontmatter):
633-
- **Title**: directory name of the skill
634-
- **Description**: first non-empty line of the markdown body (stripped of `#` heading markers), or "No description"
635-
- **Footer**: file count only
636-
637-
**Notes**:
638-
- Parse frontmatter with a YAML parser — content between the first two `---` lines
639-
- Spec defines `name` max 64 chars, `description` max 1024 chars — truncate to ~200 in card
640-
- `compatibility` field could render as small tags if present
641-
642-
**Modify**: `frontend/admin-ui/` (skill card component)
551+
Implemented. Frontend parses YAML frontmatter from `instructions` field via `lib/frontmatter.js`. Canonical skills (valid frontmatter with `name`) render structured cards with description, license/compatibility badges, and file count. Non-canonical skills fall back to store name/description + truncated instructions.
643552

644553
---
645554

646-
### Skill Package Upload (ZIP/tar.gz)
647-
648-
**Problem**: Current uploader only allows uploading files one at a time with no folder support. Many skills consist of multiple files (`scripts/`, `references/`, `assets/`, templates) that need directory structure. Uploading a complex skill is painful and error-prone.
649-
650-
**Solution**: Support uploading a skill as a compressed package that preserves the full directory tree.
651-
652-
**Upload flow**:
653-
1. User uploads a `.zip` or `.tar.gz` in the Admin GUI
654-
2. Backend extracts and validates: must contain a `SKILL.md` at the root (or one level deep if the archive wraps the skill in a single top-level directory)
655-
3. Store the full tree — scripts, references, assets, everything
656-
4. Parse `SKILL.md` frontmatter for the Skill Card View (see Skill Card View Formatter)
657-
5. Expose a file browser in the skill detail view so the user can see what's inside
658-
659-
**Requirements**:
660-
- Preserve directory structure as-is on extraction
661-
- The backend must resolve relative paths referenced in SKILL.md (e.g. `./reference/mcp_best_practices.md`, `./templates/viewer.html`) so the agent can read them at runtime
662-
- Scripts are stored but **not executed** — available for the agent to read/reference, not to run
663-
- Support both single-file skills (simple upload, same as today) and multi-file package upload
664-
- Also support drag-and-drop of folders as an alternative (`webkitdirectory` or File System Access API)
665-
666-
**Notes**:
667-
- Many skills reference files with relative paths (`./reference/`, `./scripts/`) — the storage layer must preserve these paths so they resolve correctly when the agent loads them into context
668-
- Backend needs a batch upload endpoint that handles paths relative to the skill root
555+
### ~~Skill Package Upload (ZIP/tar.gz)~~
669556

670-
**Modify**: `frontend/admin-ui/` (upload component), `server/api/admin/` (upload endpoint), `server/store/` (skill storage)
557+
Implemented. `POST /skills/{id}/package` extracts ZIP or tar.gz, requires `SKILL.md` at root (or one level deep — auto-stripped). Preserves directory structure in `data/skills/{id}/`. If SKILL.md has valid frontmatter, `name` and `description` are extracted for the store; otherwise name defaults to archive filename. `SkillDialog.vue` has Manual | Package segmented toggle — Package mode shows a drop zone for compressed files.
671558

672559
---
673560

frontend/admin-ui/package-lock.json

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/admin-ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"dependencies": {
1212
"@tailwindcss/vite": "^4.1.18",
13+
"js-yaml": "^4.1.1",
1314
"marked": "^17.0.2",
1415
"pinia": "^3.0.4",
1516
"tailwindcss": "^4.1.18",
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<template>
2+
<div class="inline-flex items-center bg-piedra-800 rounded-lg border border-piedra-700/50 p-0.5">
3+
<button
4+
v-for="opt in options" :key="opt.value"
5+
type="button"
6+
:disabled="opt.disabled"
7+
@click="!opt.disabled && $emit('update:modelValue', opt.value)"
8+
class="flex items-center gap-1 px-2.5 py-1 text-[10px] font-medium rounded-md transition-colors"
9+
:class="modelValue === opt.value
10+
? 'bg-piedra-700 text-arena-100'
11+
: opt.disabled
12+
? 'text-arena-600 cursor-not-allowed'
13+
: 'text-arena-500 hover:text-arena-300'"
14+
>
15+
<Icon v-if="opt.icon" :name="opt.icon" size="xs" />
16+
<span>{{ opt.label }}</span>
17+
</button>
18+
</div>
19+
</template>
20+
21+
<script setup>
22+
import Icon from './Icon.vue'
23+
24+
defineProps({
25+
modelValue: { type: [String, Number, Boolean], required: true },
26+
options: { type: Array, required: true },
27+
})
28+
defineEmits(['update:modelValue'])
29+
</script>

frontend/admin-ui/src/lib/api/skills.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,16 @@ export const skillsApi = {
2323
},
2424
deleteReference: (id, filename) => request(`/skills/${id}/references/${encodeURIComponent(filename)}`, { method: 'DELETE' }),
2525
referenceUrl: (id, filename) => `${BASE}/skills/${id}/references/${encodeURIComponent(filename)}`,
26+
uploadPackage: async (id, file) => {
27+
const form = new FormData()
28+
form.append('file', file)
29+
const res = await fetch(`${BASE}/skills/${id}/package`, {
30+
method: 'POST',
31+
headers: { ...getAuthHeaders() },
32+
body: form,
33+
})
34+
const data = await res.json()
35+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
36+
return data
37+
},
2638
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import yaml from 'js-yaml'
2+
3+
const FENCE = '---'
4+
5+
export function parseFrontmatter(text) {
6+
if (!text || typeof text !== 'string') return { meta: null, body: text || '' }
7+
8+
const trimmed = text.trimStart()
9+
if (!trimmed.startsWith(FENCE)) return { meta: null, body: text }
10+
11+
const afterFirst = trimmed.indexOf('\n')
12+
if (afterFirst === -1) return { meta: null, body: text }
13+
14+
const rest = trimmed.slice(afterFirst + 1)
15+
const closing = rest.indexOf('\n' + FENCE)
16+
if (closing === -1) return { meta: null, body: text }
17+
18+
const yamlBlock = rest.slice(0, closing)
19+
const body = rest.slice(closing + FENCE.length + 1).replace(/^\n/, '')
20+
21+
let meta
22+
try {
23+
meta = yaml.load(yamlBlock)
24+
} catch {
25+
return { meta: null, body: text }
26+
}
27+
28+
if (!meta || typeof meta !== 'object' || !meta.name) return { meta: null, body: text }
29+
30+
return { meta, body }
31+
}
32+
33+
export function skillCardData(skill) {
34+
const { meta } = parseFrontmatter(skill.instructions)
35+
36+
const hasStoreName = skill.name && skill.name.trim()
37+
const hasStoreDesc = skill.description && skill.description.trim()
38+
39+
if (meta) {
40+
const badges = []
41+
if (meta.license) badges.push(meta.license)
42+
if (meta.compatibility) badges.push(String(meta.compatibility).length > 40 ? String(meta.compatibility).slice(0, 37) + '...' : String(meta.compatibility))
43+
44+
return {
45+
canonical: true,
46+
name: hasStoreName ? skill.name : (meta.name || skill.name),
47+
description: hasStoreDesc ? skill.description : (meta.description || skill.description || ''),
48+
badges,
49+
metadata: typeof meta.metadata === 'object' ? meta.metadata : null,
50+
}
51+
}
52+
53+
const firstLine = (skill.instructions || '').trim().split('\n').find(l => l.trim())
54+
const fallbackDesc = firstLine ? firstLine.replace(/^#+\s*/, '').trim() : ''
55+
56+
return {
57+
canonical: false,
58+
name: skill.name,
59+
description: skill.description || fallbackDesc || 'No description',
60+
badges: [],
61+
metadata: null,
62+
}
63+
}

frontend/admin-ui/src/views/conversations/ConversationDetail.vue

Lines changed: 13 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -78,38 +78,10 @@
7878
<div class="w-px h-4 bg-piedra-700/50" />
7979

8080
<!-- Perspective toggle -->
81-
<div v-if="pairId || conversation?.perspective" class="flex items-center bg-piedra-800 rounded-lg border border-piedra-700/50 p-0.5">
82-
<button
83-
@click="switchPerspective('user')"
84-
:disabled="!canSwitch('user')"
85-
class="px-2.5 py-1 text-[10px] font-medium rounded-md transition-colors"
86-
:class="activePerspective === 'user'
87-
? 'bg-piedra-700 text-arena-100'
88-
: pairId ? 'text-arena-500 hover:text-arena-300 cursor-pointer' : 'text-arena-600 cursor-not-allowed'"
89-
>User</button>
90-
<button
91-
@click="switchPerspective('admin')"
92-
:disabled="!canSwitch('admin')"
93-
class="px-2.5 py-1 text-[10px] font-medium rounded-md transition-colors"
94-
:class="activePerspective === 'admin'
95-
? 'bg-piedra-700 text-arena-100'
96-
: pairId ? 'text-arena-500 hover:text-arena-300 cursor-pointer' : 'text-arena-600 cursor-not-allowed'"
97-
>Admin</button>
98-
</div>
81+
<SegmentedControl v-if="pairId || conversation?.perspective" :modelValue="activePerspective" @update:modelValue="switchPerspective" :options="perspectiveOptions" />
9982

10083
<!-- View toggle -->
101-
<div class="flex items-center bg-piedra-800 rounded-lg border border-piedra-700/50 p-0.5">
102-
<button
103-
@click="showRaw = false"
104-
class="px-2.5 py-1 text-[10px] font-medium rounded-md transition-colors"
105-
:class="!showRaw ? 'bg-piedra-700 text-arena-100' : 'text-arena-500 hover:text-arena-300'"
106-
>Messages</button>
107-
<button
108-
@click="showRaw = true"
109-
class="px-2.5 py-1 text-[10px] font-medium rounded-md transition-colors"
110-
:class="showRaw ? 'bg-piedra-700 text-arena-100' : 'text-arena-500 hover:text-arena-300'"
111-
>Raw</button>
112-
</div>
84+
<SegmentedControl v-model="showRaw" :options="viewOptions" />
11385

11486
<!-- Actions -->
11587
<button
@@ -272,6 +244,7 @@ import { useDataStore } from '../../lib/stores/data.js'
272244
import Badge from '../../components/Badge.vue'
273245
import Icon from '../../components/Icon.vue'
274246
import EmptyState from '../../components/EmptyState.vue'
247+
import SegmentedControl from '../../components/SegmentedControl.vue'
275248
276249
const MSG_PAGE_SIZE = 50
277250
@@ -309,6 +282,16 @@ function canSwitch(target) {
309282
return !!pairId.value
310283
}
311284
285+
const perspectiveOptions = computed(() => [
286+
{ label: 'User', value: 'user', disabled: !canSwitch('user') },
287+
{ label: 'Admin', value: 'admin', disabled: !canSwitch('admin') },
288+
])
289+
290+
const viewOptions = [
291+
{ label: 'Messages', value: false },
292+
{ label: 'Raw', value: true },
293+
]
294+
312295
function switchPerspective(target) {
313296
if (!canSwitch(target)) return
314297
emit('navigate', pairId.value)

0 commit comments

Comments
 (0)