Compare commits
4 Commits
9a7b35b677
...
v1.9.1-ski
| Author | SHA1 | Date | |
|---|---|---|---|
| adb5d7b3bb | |||
| 80fd3d9fa9 | |||
| eaacd432e8 | |||
| 529a77c959 |
@@ -3,7 +3,6 @@
|
|||||||
## Code Reviewer
|
## Code Reviewer
|
||||||
---
|
---
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
tools: [view_file, list_dir, grep, find_files]
|
|
||||||
description: Reviews code for bugs, security issues, and maintainability. Read-only.
|
description: Reviews code for bugs, security issues, and maintainability. Read-only.
|
||||||
---
|
---
|
||||||
You review code. Find real problems, not style nits.
|
You review code. Find real problems, not style nits.
|
||||||
@@ -33,7 +32,6 @@ If nothing critical or major, say so in one line. Do not pad.
|
|||||||
## Debugger
|
## Debugger
|
||||||
---
|
---
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
tools: [view_file, list_dir, grep, find_files]
|
|
||||||
description: Diagnoses bugs from error messages, logs, or described symptoms.
|
description: Diagnoses bugs from error messages, logs, or described symptoms.
|
||||||
---
|
---
|
||||||
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
||||||
@@ -62,7 +60,6 @@ Output:
|
|||||||
## Refactorer
|
## Refactorer
|
||||||
---
|
---
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
tools: [view_file, list_dir, grep, find_files]
|
|
||||||
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
||||||
---
|
---
|
||||||
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
||||||
@@ -95,7 +92,6 @@ Output:
|
|||||||
## Architect
|
## Architect
|
||||||
---
|
---
|
||||||
temperature: 0.5
|
temperature: 0.5
|
||||||
tools: [view_file, list_dir, grep, find_files]
|
|
||||||
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
||||||
---
|
---
|
||||||
You design. You produce build plans, not code.
|
You design. You produce build plans, not code.
|
||||||
@@ -128,7 +124,6 @@ Output:
|
|||||||
## Security Auditor
|
## Security Auditor
|
||||||
---
|
---
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
tools: [view_file, list_dir, grep, find_files]
|
|
||||||
description: Audits code for security vulnerabilities. Read-only.
|
description: Audits code for security vulnerabilities. Read-only.
|
||||||
---
|
---
|
||||||
You audit for security issues. Concrete findings only, no generic warnings.
|
You audit for security issues. Concrete findings only, no generic warnings.
|
||||||
@@ -165,7 +160,6 @@ If the code is clean, say so. Do not invent findings.
|
|||||||
## Prompt Builder
|
## Prompt Builder
|
||||||
---
|
---
|
||||||
temperature: 0.4
|
temperature: 0.4
|
||||||
tools: [view_file, list_dir, grep, find_files]
|
|
||||||
description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch.
|
description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch.
|
||||||
---
|
---
|
||||||
You write prompts that another coding agent will execute. Your output is the prompt, not the work.
|
You write prompts that another coding agent will execute. Your output is the prompt, not the work.
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import { registerSidebarRoutes } from './routes/sidebar.js';
|
|||||||
import { registerWebSocket } from './routes/ws.js';
|
import { registerWebSocket } from './routes/ws.js';
|
||||||
import { registerModelRoutes } from './routes/models.js';
|
import { registerModelRoutes } from './routes/models.js';
|
||||||
import { registerAgentRoutes } from './routes/agents.js';
|
import { registerAgentRoutes } from './routes/agents.js';
|
||||||
|
import { registerSkillsRoutes } from './routes/skills.js';
|
||||||
import { createInferenceRunner } from './services/inference.js';
|
import { createInferenceRunner } from './services/inference.js';
|
||||||
import { createBroker } from './services/broker.js';
|
import { createBroker } from './services/broker.js';
|
||||||
|
import { listSkills } from './services/skills.js';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -62,6 +64,15 @@ async function main() {
|
|||||||
registerSidebarRoutes(app, sql);
|
registerSidebarRoutes(app, sql);
|
||||||
registerChatRoutes(app, sql, broker);
|
registerChatRoutes(app, sql, broker);
|
||||||
|
|
||||||
|
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or
|
||||||
|
// missing /data/skills is non-fatal — the skill tools just return empty.
|
||||||
|
try {
|
||||||
|
const skills = await listSkills();
|
||||||
|
app.log.info(`skills loaded: ${skills.length}`);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn({ err }, 'skills boot walk failed');
|
||||||
|
}
|
||||||
|
|
||||||
const inference = createInferenceRunner(
|
const inference = createInferenceRunner(
|
||||||
{
|
{
|
||||||
sql,
|
sql,
|
||||||
@@ -113,6 +124,33 @@ async function main() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
registerSkillsRoutes(app, sql, {
|
||||||
|
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
||||||
|
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||||
|
},
|
||||||
|
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
||||||
|
broker.publish(sessionId, {
|
||||||
|
type: 'message_started',
|
||||||
|
message_id: userMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
role: 'user',
|
||||||
|
});
|
||||||
|
broker.publish(sessionId, {
|
||||||
|
type: 'delta',
|
||||||
|
message_id: userMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
broker.publish(sessionId, {
|
||||||
|
type: 'message_complete',
|
||||||
|
message_id: userMessageId,
|
||||||
|
chat_id: chatId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
publishSessionFrame: (sessionId, frame) => {
|
||||||
|
broker.publish(sessionId, frame);
|
||||||
|
},
|
||||||
|
});
|
||||||
registerWebSocket(app, sql, broker);
|
registerWebSocket(app, sql, broker);
|
||||||
|
|
||||||
const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist');
|
const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist');
|
||||||
|
|||||||
156
apps/server/src/routes/skills.ts
Normal file
156
apps/server/src/routes/skills.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import type { Chat } from '../types/api.js';
|
||||||
|
import { getSkillBody, listSkills } from '../services/skills.js';
|
||||||
|
|
||||||
|
// Batch 9.6 slash-invoke handlers. Mirrors the MessageHandlers shape in
|
||||||
|
// routes/messages.ts so index.ts can pass thin adapters around broker +
|
||||||
|
// inference runner without skills.ts importing them directly.
|
||||||
|
export interface SkillInvokeHandlers {
|
||||||
|
enqueueInference: (
|
||||||
|
sessionId: string,
|
||||||
|
chatId: string,
|
||||||
|
assistantMessageId: string,
|
||||||
|
user: string,
|
||||||
|
) => void;
|
||||||
|
publishUserMessage: (
|
||||||
|
sessionId: string,
|
||||||
|
chatId: string,
|
||||||
|
userMessageId: string,
|
||||||
|
content: string,
|
||||||
|
) => void;
|
||||||
|
publishSessionFrame: (
|
||||||
|
sessionId: string,
|
||||||
|
frame: Record<string, unknown> & { type: string },
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SkillInvokeBody = z.object({
|
||||||
|
skill_name: z.string().min(1),
|
||||||
|
// Optional — server fills in a default if absent or whitespace-only so the
|
||||||
|
// model always has something to act on (matches the spec's "Apply this
|
||||||
|
// skill." filler).
|
||||||
|
user_message: z.string().max(64_000).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_USER_MESSAGE = 'Apply this skill.';
|
||||||
|
|
||||||
|
export function registerSkillsRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
sql: Sql,
|
||||||
|
handlers: SkillInvokeHandlers,
|
||||||
|
): void {
|
||||||
|
// Debug/admin surface — the model interacts with skills via the three
|
||||||
|
// skill_* tools, not through this endpoint.
|
||||||
|
app.get('/api/skills', async () => {
|
||||||
|
return { skills: await listSkills() };
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/chats/:id/skill_invoke — slash-command entry point. Loads the
|
||||||
|
// skill body server-side (clients never get to forge file content),
|
||||||
|
// persists 4 messages in one transaction (synthetic assistant tool_use,
|
||||||
|
// synthetic tool result, real user message, streaming assistant), and
|
||||||
|
// enqueues inference against the updated history.
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/chats/:id/skill_invoke',
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = SkillInvokeBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
const { skill_name } = parsed.data;
|
||||||
|
const userText = parsed.data.user_message?.trim() ? parsed.data.user_message : DEFAULT_USER_MESSAGE;
|
||||||
|
|
||||||
|
const chatRows = await sql<Chat[]>`
|
||||||
|
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
||||||
|
`;
|
||||||
|
if (chatRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat not found' };
|
||||||
|
}
|
||||||
|
const chat = chatRows[0]!;
|
||||||
|
const sessionId = chat.session_id;
|
||||||
|
|
||||||
|
const body = await getSkillBody(skill_name);
|
||||||
|
if (body === null) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCallId = randomUUID();
|
||||||
|
const toolCalls = [{ id: toolCallId, name: 'skill_use', args: { name: skill_name } }];
|
||||||
|
const toolResults = { tool_call_id: toolCallId, output: body, truncated: false };
|
||||||
|
|
||||||
|
const result = await sql.begin(async (tx) => {
|
||||||
|
const [synthAssistant] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, tool_calls, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chat.id}, 'assistant', '', ${sql.json(toolCalls as never)}, 'complete', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const [toolMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, tool_results, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chat.id}, 'tool', '', ${sql.json(toolResults as never)}, 'complete', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const [userMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chat.id}, 'user', ${userText}, 'complete', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||||
|
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
||||||
|
return {
|
||||||
|
synth_assistant_id: synthAssistant!.id,
|
||||||
|
tool_message_id: toolMsg!.id,
|
||||||
|
user_message_id: userMsg!.id,
|
||||||
|
assistant_message_id: assistantMsg!.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Synthetic frames so useSessionStream's reducer reflects the new
|
||||||
|
// history without a refetch. Frame shapes match the streaming-inference
|
||||||
|
// protocol (see services/inference.ts InferenceFrame).
|
||||||
|
handlers.publishSessionFrame(sessionId, {
|
||||||
|
type: 'message_started',
|
||||||
|
message_id: result.synth_assistant_id,
|
||||||
|
chat_id: chat.id,
|
||||||
|
role: 'assistant',
|
||||||
|
});
|
||||||
|
handlers.publishSessionFrame(sessionId, {
|
||||||
|
type: 'tool_call',
|
||||||
|
message_id: result.synth_assistant_id,
|
||||||
|
chat_id: chat.id,
|
||||||
|
tool_call: toolCalls[0]!,
|
||||||
|
});
|
||||||
|
handlers.publishSessionFrame(sessionId, {
|
||||||
|
type: 'message_complete',
|
||||||
|
message_id: result.synth_assistant_id,
|
||||||
|
chat_id: chat.id,
|
||||||
|
});
|
||||||
|
// The tool_result frame's reducer branch creates the tool-role message
|
||||||
|
// in-place when it doesn't already exist — no separate message_started
|
||||||
|
// is needed for the tool side.
|
||||||
|
handlers.publishSessionFrame(sessionId, {
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_message_id: result.tool_message_id,
|
||||||
|
tool_call_id: toolCallId,
|
||||||
|
chat_id: chat.id,
|
||||||
|
output: body,
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText);
|
||||||
|
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
|
||||||
|
|
||||||
|
reply.code(202);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,14 @@ const GLOBAL_AGENTS_PATH = '/data/AGENTS.md';
|
|||||||
const CACHE_TTL_MS = 60_000;
|
const CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
// Tools whitelist universe matches services/tools.ts ALL_TOOLS. Keep in sync.
|
// Tools whitelist universe matches services/tools.ts ALL_TOOLS. Keep in sync.
|
||||||
const ALL_TOOL_NAMES = ['view_file', 'list_dir', 'grep', 'find_files', 'git_status'] as const;
|
// Batch 9.6: skill_find / skill_use / skill_resource added. Agents without an
|
||||||
|
// explicit `tools:` field inherit the full default set (which now includes
|
||||||
|
// the skill tools); agents with an explicit `tools:` array must list any
|
||||||
|
// skill tool they want to use — strict opt-in.
|
||||||
|
const ALL_TOOL_NAMES = [
|
||||||
|
'view_file', 'list_dir', 'grep', 'find_files', 'git_status',
|
||||||
|
'skill_find', 'skill_use', 'skill_resource',
|
||||||
|
] as const;
|
||||||
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
||||||
const DEFAULT_TEMPERATURE = 0.7;
|
const DEFAULT_TEMPERATURE = 0.7;
|
||||||
|
|
||||||
|
|||||||
321
apps/server/src/services/skills.ts
Normal file
321
apps/server/src/services/skills.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import { join, isAbsolute, basename } from 'node:path';
|
||||||
|
import { pathGuard, PathScopeError } from './path_guard.js';
|
||||||
|
|
||||||
|
// Batch 9.6: read-only skill library. Folders under /data/skills/<group>/<skill>/
|
||||||
|
// contain a SKILL.md with YAML frontmatter (name + description) and a markdown
|
||||||
|
// body. Three tools expose the library: skill_find (search), skill_use (load
|
||||||
|
// body), skill_resource (read a support file inside the folder).
|
||||||
|
//
|
||||||
|
// Layout is intentionally uniform — scan /data/skills/*/*/SKILL.md at fixed
|
||||||
|
// depth 3. Group folders (depth 1) hold LICENSE + ATTRIBUTION.md + skill
|
||||||
|
// subfolders and are NOT themselves skills. Support files inside skill
|
||||||
|
// folders are reachable via skill_resource, never auto-parsed.
|
||||||
|
//
|
||||||
|
// Cache model mirrors agents.ts: walk on first access, TTL re-walk to pick up
|
||||||
|
// new skills, per-entry mtime check on body access so a hot-edited SKILL.md
|
||||||
|
// is re-read without a restart. No watcher.
|
||||||
|
|
||||||
|
const SKILLS_ROOT = '/data/skills';
|
||||||
|
const MAX_RESOURCE_BYTES = 5 * 1024 * 1024;
|
||||||
|
const LIST_CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
export interface Skill {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
path: string;
|
||||||
|
mtime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedSkill extends Skill {
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, CachedSkill>();
|
||||||
|
let lastWalkedAt = 0;
|
||||||
|
|
||||||
|
// ---- Frontmatter parser ----------------------------------------------------
|
||||||
|
// Minimal `---\n...\n---` extractor. Only `name` and `description` keys are
|
||||||
|
// honored; other frontmatter keys are silently ignored for forward-compat
|
||||||
|
// with the anthropics/skills upstream spec.
|
||||||
|
|
||||||
|
interface Frontmatter {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripQuotes(s: string): string {
|
||||||
|
if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[0] === s[s.length - 1]) {
|
||||||
|
return s.slice(1, -1);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontmatter(yaml: string): Frontmatter {
|
||||||
|
const fm: Frontmatter = {};
|
||||||
|
for (const raw of yaml.split('\n')) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (line.length === 0) continue;
|
||||||
|
const colon = line.indexOf(':');
|
||||||
|
if (colon < 0) continue;
|
||||||
|
const key = line.slice(0, colon).trim();
|
||||||
|
const val = stripQuotes(line.slice(colon + 1).trim());
|
||||||
|
if (key === 'name') fm.name = val;
|
||||||
|
else if (key === 'description') fm.description = val;
|
||||||
|
}
|
||||||
|
return fm;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedSkillFile {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSkillFile(content: string): ParsedSkillFile {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
let openIdx = -1;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const t = lines[i]!.trim();
|
||||||
|
if (t === '') continue;
|
||||||
|
if (t === '---') openIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (openIdx < 0) throw new Error('missing opening --- fence');
|
||||||
|
let closeIdx = -1;
|
||||||
|
for (let i = openIdx + 1; i < lines.length; i++) {
|
||||||
|
if (lines[i]!.trim() === '---') { closeIdx = i; break; }
|
||||||
|
}
|
||||||
|
if (closeIdx < 0) throw new Error('missing closing --- fence');
|
||||||
|
|
||||||
|
const yamlText = lines.slice(openIdx + 1, closeIdx).join('\n');
|
||||||
|
const body = lines.slice(closeIdx + 1).join('\n');
|
||||||
|
|
||||||
|
const fm = parseFrontmatter(yamlText);
|
||||||
|
if (!fm.name) throw new Error('frontmatter missing name');
|
||||||
|
if (!fm.description) throw new Error('frontmatter missing description');
|
||||||
|
return { name: fm.name, description: fm.description, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tree walk -------------------------------------------------------------
|
||||||
|
|
||||||
|
// Fixed depth-3 scan: /data/skills/<group>/<skill>/SKILL.md. Two layers of
|
||||||
|
// readdir, no recursion. Group folders without SKILL.md are skipped silently;
|
||||||
|
// LICENSE / ATTRIBUTION.md / other non-SKILL.md files are ignored entirely.
|
||||||
|
// Returns all parseable skills as-found — dedup + collision logging happens
|
||||||
|
// in ensureCache where the sort order is established.
|
||||||
|
async function walkSkills(root: string): Promise<CachedSkill[]> {
|
||||||
|
const found: CachedSkill[] = [];
|
||||||
|
let groups;
|
||||||
|
try {
|
||||||
|
groups = await fs.readdir(root, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
for (const group of groups) {
|
||||||
|
if (!group.isDirectory() || group.name.startsWith('.')) continue;
|
||||||
|
const groupPath = join(root, group.name);
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(groupPath, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
||||||
|
const skillFolder = join(groupPath, entry.name);
|
||||||
|
const skillFile = join(skillFolder, 'SKILL.md');
|
||||||
|
let stat;
|
||||||
|
try {
|
||||||
|
stat = await fs.stat(skillFile);
|
||||||
|
} catch {
|
||||||
|
continue; // folder without SKILL.md — silent skip
|
||||||
|
}
|
||||||
|
if (!stat.isFile()) continue;
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(skillFile, 'utf8');
|
||||||
|
const parsed = parseSkillFile(content);
|
||||||
|
found.push({
|
||||||
|
name: parsed.name,
|
||||||
|
description: parsed.description,
|
||||||
|
path: skillFolder,
|
||||||
|
mtime: stat.mtimeMs,
|
||||||
|
body: parsed.body,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(`skills: failed to parse ${skillFile} — ${reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Cache ----------------------------------------------------------------
|
||||||
|
|
||||||
|
async function ensureCache(): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cache.size > 0 && now - lastWalkedAt < LIST_CACHE_TTL_MS) return;
|
||||||
|
let stat;
|
||||||
|
try {
|
||||||
|
stat = await fs.stat(SKILLS_ROOT);
|
||||||
|
} catch {
|
||||||
|
cache.clear();
|
||||||
|
lastWalkedAt = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
cache.clear();
|
||||||
|
lastWalkedAt = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const found = await walkSkills(SKILLS_ROOT);
|
||||||
|
// Sort by name asc, then path asc — gives alphabetically-first-wins on
|
||||||
|
// collision and stable, deterministic ordering for /api/skills + skill_find.
|
||||||
|
found.sort((a, b) => {
|
||||||
|
const n = a.name.localeCompare(b.name);
|
||||||
|
return n !== 0 ? n : a.path.localeCompare(b.path);
|
||||||
|
});
|
||||||
|
cache.clear();
|
||||||
|
const winnerPath = new Map<string, string>();
|
||||||
|
for (const skill of found) {
|
||||||
|
const prev = winnerPath.get(skill.name);
|
||||||
|
if (prev) {
|
||||||
|
console.warn(
|
||||||
|
`skills: name collision "${skill.name}" — kept ${prev}, skipped ${skill.path}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
winnerPath.set(skill.name, skill.path);
|
||||||
|
cache.set(skill.name, skill);
|
||||||
|
}
|
||||||
|
lastWalkedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Public API -----------------------------------------------------------
|
||||||
|
|
||||||
|
export async function listSkills(): Promise<Skill[]> {
|
||||||
|
await ensureCache();
|
||||||
|
return Array.from(cache.values()).map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
description: s.description,
|
||||||
|
path: s.path,
|
||||||
|
mtime: s.mtime,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillSummary {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findSkills(query: string): Promise<SkillSummary[]> {
|
||||||
|
await ensureCache();
|
||||||
|
const all = Array.from(cache.values());
|
||||||
|
const q = (query ?? '').trim().toLowerCase();
|
||||||
|
if (q === '' || q === '*') {
|
||||||
|
return all.map((s) => ({ name: s.name, description: s.description }));
|
||||||
|
}
|
||||||
|
// name match weighted 2x description match. No fancy ranking — substring
|
||||||
|
// scoring is enough for ≤20 skills.
|
||||||
|
const scored = all
|
||||||
|
.map((s) => {
|
||||||
|
let score = 0;
|
||||||
|
if (s.name.toLowerCase().includes(q)) score += 2;
|
||||||
|
if (s.description.toLowerCase().includes(q)) score += 1;
|
||||||
|
return { s, score };
|
||||||
|
})
|
||||||
|
.filter((x) => x.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 5);
|
||||||
|
return scored.map(({ s }) => ({ name: s.name, description: s.description }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the SKILL.md body with frontmatter stripped, or null if the skill
|
||||||
|
// is unknown. Single-entry mtime refresh: a hot edit shows up on next call.
|
||||||
|
export async function getSkillBody(name: string): Promise<string | null> {
|
||||||
|
await ensureCache();
|
||||||
|
const cached = cache.get(name);
|
||||||
|
if (!cached) return null;
|
||||||
|
|
||||||
|
let stat;
|
||||||
|
try {
|
||||||
|
stat = await fs.stat(join(cached.path, 'SKILL.md'));
|
||||||
|
} catch {
|
||||||
|
cache.delete(name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (stat.mtimeMs === cached.mtime) return cached.body;
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(join(cached.path, 'SKILL.md'), 'utf8');
|
||||||
|
const parsed = parseSkillFile(raw);
|
||||||
|
if (parsed.name !== name) {
|
||||||
|
// Skill renamed itself; drop the stale entry. Next listSkills() walks.
|
||||||
|
cache.delete(name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
cached.body = parsed.body;
|
||||||
|
cached.description = parsed.description;
|
||||||
|
cached.mtime = stat.mtimeMs;
|
||||||
|
return cached.body;
|
||||||
|
} catch (err) {
|
||||||
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(`skills: re-parse failed for ${name} — ${reason}`);
|
||||||
|
cache.delete(name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SkillResourceErrorCode = 'unknown_skill' | 'unknown_resource' | 'path_escape';
|
||||||
|
|
||||||
|
export type SkillResourceResult =
|
||||||
|
| { ok: true; content: string }
|
||||||
|
| { ok: false; code: SkillResourceErrorCode; message: string };
|
||||||
|
|
||||||
|
export async function getSkillResource(
|
||||||
|
name: string,
|
||||||
|
relativePath: string,
|
||||||
|
): Promise<SkillResourceResult> {
|
||||||
|
await ensureCache();
|
||||||
|
const cached = cache.get(name);
|
||||||
|
if (!cached) {
|
||||||
|
return { ok: false, code: 'unknown_skill', message: `unknown skill: ${name}` };
|
||||||
|
}
|
||||||
|
if (typeof relativePath !== 'string' || relativePath.trim() === '') {
|
||||||
|
return { ok: false, code: 'unknown_resource', message: 'path is required' };
|
||||||
|
}
|
||||||
|
// Syntactic pre-check — catches the common "../../etc/passwd" attempt
|
||||||
|
// before realpath dereferences any symlinks.
|
||||||
|
if (isAbsolute(relativePath) || relativePath.split(/[\\/]/).some((seg) => seg === '..')) {
|
||||||
|
return { ok: false, code: 'path_escape', message: `path escapes skill folder: ${relativePath}` };
|
||||||
|
}
|
||||||
|
// SKILL.md is the manifest — skill_use is the right tool to read it.
|
||||||
|
if (basename(relativePath) === 'SKILL.md') {
|
||||||
|
return { ok: false, code: 'unknown_resource', message: 'use skill_use to read SKILL.md' };
|
||||||
|
}
|
||||||
|
let real: string;
|
||||||
|
try {
|
||||||
|
real = await pathGuard(cached.path, relativePath);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PathScopeError) {
|
||||||
|
const code: SkillResourceErrorCode = err.message.includes('escapes')
|
||||||
|
? 'path_escape'
|
||||||
|
: 'unknown_resource';
|
||||||
|
return { ok: false, code, message: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const stat = await fs.stat(real);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
return { ok: false, code: 'unknown_resource', message: 'not a file' };
|
||||||
|
}
|
||||||
|
if (stat.size > MAX_RESOURCE_BYTES) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'unknown_resource',
|
||||||
|
message: `file too large (${stat.size} bytes, max ${MAX_RESOURCE_BYTES})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const content = await fs.readFile(real, 'utf8');
|
||||||
|
return { ok: true, content };
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
|||||||
import { pathGuard, PathScopeError } from './path_guard.js';
|
import { pathGuard, PathScopeError } from './path_guard.js';
|
||||||
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
|
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
|
||||||
import { getGitMeta } from './git_meta.js';
|
import { getGitMeta } from './git_meta.js';
|
||||||
|
import { findSkills, getSkillBody, getSkillResource } from './skills.js';
|
||||||
|
|
||||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
const DEFAULT_VIEW_LINES = 200;
|
const DEFAULT_VIEW_LINES = 200;
|
||||||
@@ -300,12 +301,119 @@ export const gitStatus: ToolDef<GitStatusInputT> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Batch 9.6: skill_find, skill_use, skill_resource. Lazy-loaded markdown
|
||||||
|
// playbooks at /data/skills/. Three tools rather than one to keep each call
|
||||||
|
// cheap — the model lists, then loads, then optionally pulls support files.
|
||||||
|
|
||||||
|
const SkillFindInput = z.object({
|
||||||
|
query: z.string().optional(),
|
||||||
|
});
|
||||||
|
type SkillFindInputT = z.infer<typeof SkillFindInput>;
|
||||||
|
|
||||||
|
export const skillFind: ToolDef<SkillFindInputT> = {
|
||||||
|
name: 'skill_find',
|
||||||
|
description:
|
||||||
|
'Find skills (markdown playbooks under /data/skills) by name or description. Returns up to 5 matches. Empty query or "*" returns all available skills. Call this first to discover what skills are available.',
|
||||||
|
inputSchema: SkillFindInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'skill_find',
|
||||||
|
description:
|
||||||
|
'Find skills by name or description. Returns up to 5 matches. Empty or "*" returns all.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: { type: 'string', description: 'substring matched against skill name and description' },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input) {
|
||||||
|
return await findSkills(input.query ?? '');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SkillUseInput = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
});
|
||||||
|
type SkillUseInputT = z.infer<typeof SkillUseInput>;
|
||||||
|
|
||||||
|
export const skillUse: ToolDef<SkillUseInputT> = {
|
||||||
|
name: 'skill_use',
|
||||||
|
description:
|
||||||
|
"Load the full body of a skill's SKILL.md by name. Returns the markdown playbook to follow. Discover names via skill_find. Errors: unknown_skill.",
|
||||||
|
inputSchema: SkillUseInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'skill_use',
|
||||||
|
description: "Load the full body of a skill's SKILL.md by name.",
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', description: 'skill name from skill_find' },
|
||||||
|
},
|
||||||
|
required: ['name'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input) {
|
||||||
|
const body = await getSkillBody(input.name);
|
||||||
|
if (body === null) {
|
||||||
|
return { error: 'unknown_skill', message: `unknown skill: ${input.name}` };
|
||||||
|
}
|
||||||
|
return { body };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SkillResourceInput = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
path: z.string().min(1),
|
||||||
|
});
|
||||||
|
type SkillResourceInputT = z.infer<typeof SkillResourceInput>;
|
||||||
|
|
||||||
|
export const skillResource: ToolDef<SkillResourceInputT> = {
|
||||||
|
name: 'skill_resource',
|
||||||
|
description:
|
||||||
|
"Read a support file inside a skill's folder (e.g. references/root-cause-tracing.md). Path is relative to the skill folder. Use skill_use to read SKILL.md itself. Errors: unknown_skill, unknown_resource, path_escape.",
|
||||||
|
inputSchema: SkillResourceInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'skill_resource',
|
||||||
|
description: "Read a support file inside a skill's folder. Path is relative to the skill folder.",
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', description: 'skill name' },
|
||||||
|
path: { type: 'string', description: 'relative path under the skill folder' },
|
||||||
|
},
|
||||||
|
required: ['name', 'path'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input) {
|
||||||
|
const result = await getSkillResource(input.name, input.path);
|
||||||
|
if (!result.ok) {
|
||||||
|
return { error: result.code, message: result.message };
|
||||||
|
}
|
||||||
|
return { content: result.content };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
||||||
viewFile as ToolDef<unknown>,
|
viewFile as ToolDef<unknown>,
|
||||||
listDir as ToolDef<unknown>,
|
listDir as ToolDef<unknown>,
|
||||||
grep as ToolDef<unknown>,
|
grep as ToolDef<unknown>,
|
||||||
findFiles as ToolDef<unknown>,
|
findFiles as ToolDef<unknown>,
|
||||||
gitStatus as ToolDef<unknown>,
|
gitStatus as ToolDef<unknown>,
|
||||||
|
skillFind as ToolDef<unknown>,
|
||||||
|
skillUse as ToolDef<unknown>,
|
||||||
|
skillResource as ToolDef<unknown>,
|
||||||
];
|
];
|
||||||
|
|
||||||
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
||||||
@@ -313,12 +421,16 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
|||||||
// anything outside means the agent can mutate state and gets a tighter
|
// anything outside means the agent can mutate state and gets a tighter
|
||||||
// default (10). Every tool in v1.8.2 happens to be read-only, so the
|
// default (10). Every tool in v1.8.2 happens to be read-only, so the
|
||||||
// non-RO branch only takes effect once BooCoder lands write tools.
|
// non-RO branch only takes effect once BooCoder lands write tools.
|
||||||
|
// Batch 9.6: skill_* added; all still read-only.
|
||||||
export const READ_ONLY_TOOL_NAMES = [
|
export const READ_ONLY_TOOL_NAMES = [
|
||||||
'view_file',
|
'view_file',
|
||||||
'list_dir',
|
'list_dir',
|
||||||
'grep',
|
'grep',
|
||||||
'find_files',
|
'find_files',
|
||||||
'git_status',
|
'git_status',
|
||||||
|
'skill_find',
|
||||||
|
'skill_use',
|
||||||
|
'skill_resource',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
ViewFileResult,
|
ViewFileResult,
|
||||||
AgentsResponse,
|
AgentsResponse,
|
||||||
GitMeta,
|
GitMeta,
|
||||||
|
Skill,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -187,6 +188,20 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ message_id: body.messageId, name: body.name }),
|
body: JSON.stringify({ message_id: body.messageId, name: body.name }),
|
||||||
}),
|
}),
|
||||||
|
// Batch 9.6: slash-command invocation. Server loads the skill body
|
||||||
|
// authoritatively (client doesn't get to forge file contents), persists
|
||||||
|
// a synthetic skill_use tool_use + tool_result + user message + streaming
|
||||||
|
// assistant, and enqueues inference. Returns all 4 new message IDs.
|
||||||
|
skillInvoke: (chatId: string, skillName: string, userMessage: string | null) =>
|
||||||
|
request<{
|
||||||
|
synth_assistant_id: string;
|
||||||
|
tool_message_id: string;
|
||||||
|
user_message_id: string;
|
||||||
|
assistant_message_id: string;
|
||||||
|
}>(`/api/chats/${chatId}/skill_invoke`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ skill_name: skillName, user_message: userMessage }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
messages: {
|
messages: {
|
||||||
@@ -218,6 +233,10 @@ export const api = {
|
|||||||
request<AgentsResponse>(`/api/projects/${projectId}/agents`),
|
request<AgentsResponse>(`/api/projects/${projectId}/agents`),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
skills: {
|
||||||
|
list: () => request<{ skills: Skill[] }>('/api/skills'),
|
||||||
|
},
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
get: () => request<Record<string, unknown>>('/api/settings'),
|
get: () => request<Record<string, unknown>>('/api/settings'),
|
||||||
patch: (body: Record<string, unknown>) =>
|
patch: (body: Record<string, unknown>) =>
|
||||||
|
|||||||
@@ -231,6 +231,16 @@ export interface GitMeta {
|
|||||||
behind: number;
|
behind: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch 9.6: skill catalog row. Returned by GET /api/skills and consumed by
|
||||||
|
// the slash-command dropdown. `path` and `mtime` are exposed for debug surface
|
||||||
|
// (/api/skills) but the dropdown only renders name + description.
|
||||||
|
export interface Skill {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
path: string;
|
||||||
|
mtime: number;
|
||||||
|
}
|
||||||
|
|
||||||
// v1.9: 'settings' is an ephemeral pane kind — never persisted, always
|
// v1.9: 'settings' is an ephemeral pane kind — never persisted, always
|
||||||
// singleton per workspace. The pane hook filters it out before writing to
|
// singleton per workspace. The pane hook filters it out before writing to
|
||||||
// localStorage and dedupes on insertion via toggleSettingsPane().
|
// localStorage and dedupes on insertion via toggleSettingsPane().
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
||||||
import { Check, Plus, Send } from 'lucide-react';
|
import { Check, Plus, Send } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
@@ -22,8 +22,10 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
|
|||||||
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
||||||
import { DropOverlay } from '@/components/DropOverlay';
|
import { DropOverlay } from '@/components/DropOverlay';
|
||||||
import { AgentPicker } from '@/components/AgentPicker';
|
import { AgentPicker } from '@/components/AgentPicker';
|
||||||
|
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
import { useSkills } from '@/hooks/useSkills';
|
||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
|
|
||||||
const MAX_ATTACHMENTS = 10;
|
const MAX_ATTACHMENTS = 10;
|
||||||
@@ -44,9 +46,14 @@ interface Props {
|
|||||||
webSearchEnabled?: boolean | null;
|
webSearchEnabled?: boolean | null;
|
||||||
onSend: (content: string) => void | Promise<void>;
|
onSend: (content: string) => void | Promise<void>;
|
||||||
onForceSend?: (content: string) => void | Promise<void>;
|
onForceSend?: (content: string) => void | Promise<void>;
|
||||||
|
// Batch 9.6: slash-command dispatch. When the input parses to a known skill,
|
||||||
|
// ChatInput calls this with the skill name + the post-name args (possibly
|
||||||
|
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||||||
|
// disables slash-command dispatch (input is sent as literal text).
|
||||||
|
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend }: Props) {
|
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand }: Props) {
|
||||||
const { isMobile } = useViewport();
|
const { isMobile } = useViewport();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -61,6 +68,19 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
atIdx: number;
|
atIdx: number;
|
||||||
anchorRect: { top: number; left: number };
|
anchorRect: { top: number; left: number };
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
// Batch 9.6: slash-command dropdown. Opens when `/` is the first char of
|
||||||
|
// the input and stays open while the input is `/<word>` with no whitespace.
|
||||||
|
// Disabled entirely when the caller doesn't pass onSlashCommand.
|
||||||
|
const [slashState, setSlashState] = useState<{
|
||||||
|
query: string;
|
||||||
|
anchorRect: { top: number; left: number };
|
||||||
|
} | null>(null);
|
||||||
|
const { skills } = useSkills();
|
||||||
|
const skillsLookup = useMemo(() => {
|
||||||
|
const m = new Map<string, true>();
|
||||||
|
for (const s of skills) m.set(s.name, true);
|
||||||
|
return m;
|
||||||
|
}, [skills]);
|
||||||
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
|
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
@@ -95,6 +115,31 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
const text = value.trim();
|
const text = value.trim();
|
||||||
if (!text && attachments.length === 0) return;
|
if (!text && attachments.length === 0) return;
|
||||||
if (disabled || busy) return;
|
if (disabled || busy) return;
|
||||||
|
|
||||||
|
// Batch 9.6: slash-command dispatch. Only when no attachments and the
|
||||||
|
// input parses to a known skill. Falls through to onSend for unknown
|
||||||
|
// slash names (literal text) or when slash dispatch isn't wired.
|
||||||
|
if (onSlashCommand && attachments.length === 0 && text.startsWith('/')) {
|
||||||
|
const match = text.match(/^\/(\S+)\s*([\s\S]*)$/);
|
||||||
|
if (match && skillsLookup.has(match[1]!)) {
|
||||||
|
const skillName = match[1]!;
|
||||||
|
const args = (match[2] ?? '').trim();
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await onSlashCommand(skillName, args);
|
||||||
|
setValue('');
|
||||||
|
setAttachments([]);
|
||||||
|
setSlashState(null);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Unknown skill name — fall through and send as literal text.
|
||||||
|
}
|
||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const body = flattenToMessage(attachments, text);
|
const body = flattenToMessage(attachments, text);
|
||||||
@@ -108,6 +153,19 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSlashSelect(skillName: string) {
|
||||||
|
const next = `/${skillName} `;
|
||||||
|
setValue(next);
|
||||||
|
setSlashState(null);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (ta) {
|
||||||
|
ta.selectionStart = ta.selectionEnd = next.length;
|
||||||
|
ta.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } {
|
function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } {
|
||||||
const mirror = document.createElement('div');
|
const mirror = document.createElement('div');
|
||||||
const style = window.getComputedStyle(textarea);
|
const style = window.getComputedStyle(textarea);
|
||||||
@@ -158,6 +216,23 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
const ta = e.target;
|
const ta = e.target;
|
||||||
const pos = ta.selectionStart;
|
const pos = ta.selectionStart;
|
||||||
|
|
||||||
|
// Batch 9.6: slash-command trigger. Active while the input is a single
|
||||||
|
// slash-prefixed token with no whitespace (i.e. user is still typing the
|
||||||
|
// skill name). Hand off to args mode the moment a space appears or the
|
||||||
|
// slash leaves position 0.
|
||||||
|
if (onSlashCommand && /^\/[^\s]*$/.test(newValue)) {
|
||||||
|
const query = newValue.slice(1);
|
||||||
|
if (!slashState) {
|
||||||
|
const rect = ta.getBoundingClientRect();
|
||||||
|
setSlashState({ query, anchorRect: { top: rect.top, left: rect.left } });
|
||||||
|
} else if (slashState.query !== query) {
|
||||||
|
setSlashState({ ...slashState, query });
|
||||||
|
}
|
||||||
|
if (mentionState?.open) setMentionState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (slashState) setSlashState(null);
|
||||||
|
|
||||||
// Check for @ trigger
|
// Check for @ trigger
|
||||||
if (pos > 0 && newValue[pos - 1] === '@') {
|
if (pos > 0 && newValue[pos - 1] === '@') {
|
||||||
const charBefore = pos >= 2 ? newValue[pos - 2] : null;
|
const charBefore = pos >= 2 ? newValue[pos - 2] : null;
|
||||||
@@ -374,6 +449,9 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
if (mentionState?.open) return;
|
if (mentionState?.open) return;
|
||||||
|
// SkillSlashCommand owns Arrow/Enter/Tab/Esc via a document listener; let
|
||||||
|
// it consume them so the textarea doesn't also submit on Enter.
|
||||||
|
if (slashState) return;
|
||||||
// IME safety: never act on Enter while an IME composition is in flight
|
// IME safety: never act on Enter while an IME composition is in flight
|
||||||
// (CJK input methods commit composition via Enter). Without this, the
|
// (CJK input methods commit composition via Enter). Without this, the
|
||||||
// first Enter of a Japanese/Chinese/Korean composition would submit
|
// first Enter of a Japanese/Chinese/Korean composition would submit
|
||||||
@@ -524,6 +602,15 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
onClose={closeMention}
|
onClose={closeMention}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{slashState && (
|
||||||
|
<SkillSlashCommand
|
||||||
|
query={slashState.query}
|
||||||
|
skills={skills}
|
||||||
|
anchorRect={slashState.anchorRect}
|
||||||
|
onSelect={handleSlashSelect}
|
||||||
|
onClose={() => setSlashState(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
137
apps/web/src/components/SkillSlashCommand.tsx
Normal file
137
apps/web/src/components/SkillSlashCommand.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { Skill } from '@/api/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
query: string;
|
||||||
|
skills: Skill[];
|
||||||
|
anchorRect: { top: number; left: number };
|
||||||
|
onSelect: (skillName: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch 9.6: slash-command dropdown. Models FileMentionPopover's pattern —
|
||||||
|
// fixed-positioned popover, keyboard nav, click-outside-to-close. shadcn
|
||||||
|
// `Command` (cmdk) isn't installed in this project; per the addendum we use
|
||||||
|
// a plain div + Tailwind instead of pulling a new primitive autonomously.
|
||||||
|
|
||||||
|
// Case-insensitive prefix match on `name` only. Description is display-only
|
||||||
|
// in v1 (substring search across description is deferred to a polish batch).
|
||||||
|
function filterByPrefix(skills: Skill[], query: string): Skill[] {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const filtered = q
|
||||||
|
? skills.filter((s) => s.name.toLowerCase().startsWith(q))
|
||||||
|
: skills;
|
||||||
|
// Stable alphabetical ordering matches the server's cache order (skills.ts
|
||||||
|
// sorts on name asc) but we re-sort here so a stale client cache doesn't
|
||||||
|
// surprise the user.
|
||||||
|
return [...filtered].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillSlashCommand({ query, skills, anchorRect, onSelect, onClose }: Props) {
|
||||||
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const filtered = useMemo(() => filterByPrefix(skills, query), [skills, query]);
|
||||||
|
|
||||||
|
useEffect(() => { setHighlightIndex(0); }, [query]);
|
||||||
|
|
||||||
|
// Arrow / Enter / Tab / Escape. Bound on document so keystrokes from the
|
||||||
|
// textarea reach the popover even though focus stays in the textarea.
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
|
||||||
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
if (filtered.length === 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const target = filtered[highlightIndex] ?? filtered[0];
|
||||||
|
if (target) onSelect(target.name);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [filtered, highlightIndex, onSelect, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleMouseDown(e: MouseEvent) {
|
||||||
|
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleMouseDown);
|
||||||
|
return () => document.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = popoverRef.current?.querySelector('[data-highlighted="true"]');
|
||||||
|
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||||
|
}, [highlightIndex]);
|
||||||
|
|
||||||
|
// Anchor sits above the input — translate(-100%) on Y so the dropdown
|
||||||
|
// expands upward from the anchor point rather than over the textarea.
|
||||||
|
const style = {
|
||||||
|
top: anchorRect.top,
|
||||||
|
left: anchorRect.left,
|
||||||
|
transform: 'translateY(-100%)',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={popoverRef}
|
||||||
|
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<div className="text-xs text-muted-foreground px-2 py-1">
|
||||||
|
{query ? `No skill starts with "/${query}"` : 'No skills available'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={popoverRef}
|
||||||
|
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{filtered.map((skill, i) => (
|
||||||
|
<button
|
||||||
|
key={skill.name}
|
||||||
|
type="button"
|
||||||
|
data-highlighted={i === highlightIndex}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
||||||
|
i === highlightIndex && 'bg-muted',
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => setHighlightIndex(i)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// mousedown not click — click runs after blur/focus shuffles which
|
||||||
|
// can race with the textarea's onBlur close path.
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect(skill.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="font-mono text-xs font-bold text-foreground">/{skill.name}</div>
|
||||||
|
<div
|
||||||
|
className="text-xs text-muted-foreground overflow-hidden"
|
||||||
|
style={{
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skill.description}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -96,6 +96,18 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
}
|
}
|
||||||
}, [chatId]);
|
}, [chatId]);
|
||||||
|
|
||||||
|
// Batch 9.6: slash-command dispatch. Sent regardless of streaming state —
|
||||||
|
// matches the existing /compact precedent (which also fires immediately).
|
||||||
|
// Empty args go to the server as null; the server fills in a default user
|
||||||
|
// message ("Apply this skill.") so the model has something to act on.
|
||||||
|
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
|
||||||
|
try {
|
||||||
|
await api.chats.skillInvoke(chatId, skillName, userMessage.length > 0 ? userMessage : null);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
|
||||||
|
}
|
||||||
|
}, [chatId]);
|
||||||
|
|
||||||
function removeQueued(idx: number) {
|
function removeQueued(idx: number) {
|
||||||
setQueue((prev) => prev.filter((_, i) => i !== idx));
|
setQueue((prev) => prev.filter((_, i) => i !== idx));
|
||||||
}
|
}
|
||||||
@@ -183,6 +195,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
webSearchEnabled={webSearchEnabled}
|
webSearchEnabled={webSearchEnabled}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onForceSend={streaming ? handleForceSend : undefined}
|
onForceSend={streaming ? handleForceSend : undefined}
|
||||||
|
onSlashCommand={handleSlashCommand}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
43
apps/web/src/hooks/useSkills.ts
Normal file
43
apps/web/src/hooks/useSkills.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import type { Skill } from '@/api/types';
|
||||||
|
|
||||||
|
// Batch 9.6: shared in-memory cache for the slash-command dropdown. One fetch
|
||||||
|
// per process; subsequent mounts of useSkills() return the cached list and
|
||||||
|
// don't re-hit /api/skills. Matches the useSidebar / useChatStatus module-
|
||||||
|
// singleton pattern so the dropdown stays cheap even with many ChatInputs
|
||||||
|
// mounted at once.
|
||||||
|
|
||||||
|
let cachedSkills: Skill[] | null = null;
|
||||||
|
let inflight: Promise<Skill[]> | null = null;
|
||||||
|
const subscribers = new Set<(s: Skill[]) => void>();
|
||||||
|
|
||||||
|
async function loadSkills(): Promise<Skill[]> {
|
||||||
|
if (inflight) return inflight;
|
||||||
|
inflight = api.skills
|
||||||
|
.list()
|
||||||
|
.then((r) => {
|
||||||
|
cachedSkills = r.skills;
|
||||||
|
for (const sub of subscribers) {
|
||||||
|
try { sub(cachedSkills); } catch { /* swallow */ }
|
||||||
|
}
|
||||||
|
return cachedSkills;
|
||||||
|
})
|
||||||
|
.finally(() => { inflight = null; });
|
||||||
|
return inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSkills(): { skills: Skill[]; loaded: boolean } {
|
||||||
|
const [skills, setSkills] = useState<Skill[]>(cachedSkills ?? []);
|
||||||
|
const [loaded, setLoaded] = useState<boolean>(cachedSkills !== null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
subscribers.add(setSkills);
|
||||||
|
if (cachedSkills === null) {
|
||||||
|
void loadSkills().then(() => setLoaded(true)).catch(() => setLoaded(true));
|
||||||
|
}
|
||||||
|
return () => { subscribers.delete(setSkills); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { skills, loaded };
|
||||||
|
}
|
||||||
@@ -9,15 +9,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
||||||
volumes:
|
volumes:
|
||||||
# Read-only mount for legacy/existing project add-existing flow.
|
- /opt:/opt
|
||||||
- /opt:/opt:ro
|
|
||||||
# Writable mount only for the create-new-project bootstrap target.
|
|
||||||
# Host must `mkdir -p /opt/projects` before container start.
|
|
||||||
- /opt/projects:/opt/projects:rw
|
- /opt/projects:/opt/projects:rw
|
||||||
- ./secrets/boocode_gitea:/root/.ssh/id_ed25519:ro
|
- ./secrets/boocode_gitea:/root/.ssh/id_ed25519:ro
|
||||||
# v1.8.1: global agents file. Host seeds it once before deploy:
|
- ./data:/data
|
||||||
# cp /opt/boocode/AGENTS.md /opt/boocode/data/AGENTS.md
|
- /opt/skills:/data/skills
|
||||||
- ./data:/data:ro
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- boocode_db
|
- boocode_db
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
Reference in New Issue
Block a user