blyuecode 0.3.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { Provider } from './provider.js';
2
2
  import { getToolDefs, executeTool } from './tools.js';
3
3
  import { loadSession, saveSession, clearSession } from './session.js';
4
+ import { formatSkillForPrompt } from './skills.js';
4
5
  const SYSTEM_PROMPT = 'You are BLYUE, a coding assistant running in a terminal. ' +
5
6
  'Use tools to read, write, and run commands. ' +
6
7
  'If n8n tools are available, you can also manage n8n workflows. ' +
@@ -29,6 +30,16 @@ export class Agent {
29
30
  }
30
31
  catch { }
31
32
  }
33
+ buildSystemMessage(skills) {
34
+ let content = SYSTEM_PROMPT;
35
+ if (skills.length > 0) {
36
+ content += '\n\n# Active skills\n';
37
+ for (const sk of skills) {
38
+ content += '\n' + formatSkillForPrompt(sk) + '\n';
39
+ }
40
+ }
41
+ return { role: 'system', content };
42
+ }
32
43
  historySize() {
33
44
  return this.messages.length - 1;
34
45
  }
@@ -63,7 +74,8 @@ export class Agent {
63
74
  return null;
64
75
  }
65
76
  }
66
- async *run(userInput) {
77
+ async *run(userInput, opts) {
78
+ this.messages[0] = this.buildSystemMessage(opts?.activeSkills ?? []);
67
79
  this.messages.push({ role: 'user', content: userInput });
68
80
  this.persist();
69
81
  const compacted = await this.maybeAutoCompact();
@@ -0,0 +1,88 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ const SKILLS_DIR = path.join(os.homedir(), '.blyue', 'skills');
5
+ export function skillsDir() {
6
+ return SKILLS_DIR;
7
+ }
8
+ export function ensureSkillsDir() {
9
+ if (!fs.existsSync(SKILLS_DIR)) {
10
+ fs.mkdirSync(SKILLS_DIR, { recursive: true });
11
+ }
12
+ }
13
+ function stripQuotes(s) {
14
+ return s.replace(/^["']|["']$/g, '').trim();
15
+ }
16
+ function parseFrontmatter(raw) {
17
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
18
+ if (!m)
19
+ return null;
20
+ const [, front, body] = m;
21
+ const meta = {};
22
+ for (const line of front.split(/\r?\n/)) {
23
+ if (!line.trim() || line.trim().startsWith('#'))
24
+ continue;
25
+ const kv = line.match(/^(\w+):\s*(.*)$/);
26
+ if (!kv)
27
+ continue;
28
+ const [, key, rawVal] = kv;
29
+ if (key === 'triggers') {
30
+ const cleaned = rawVal.replace(/^\[|\]$/g, '');
31
+ meta.triggers = cleaned
32
+ .split(',')
33
+ .map((s) => stripQuotes(s))
34
+ .filter(Boolean);
35
+ }
36
+ else {
37
+ meta[key] = stripQuotes(rawVal);
38
+ }
39
+ }
40
+ return { meta, body: body.trim() };
41
+ }
42
+ function loadOne(skillFile) {
43
+ try {
44
+ const raw = fs.readFileSync(skillFile, 'utf8');
45
+ const parsed = parseFrontmatter(raw);
46
+ if (!parsed)
47
+ return null;
48
+ const fallbackName = path.basename(path.dirname(skillFile));
49
+ return {
50
+ name: parsed.meta.name || fallbackName,
51
+ description: parsed.meta.description || '',
52
+ triggers: parsed.meta.triggers || [],
53
+ body: parsed.body,
54
+ path: skillFile,
55
+ };
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ export function listSkills() {
62
+ if (!fs.existsSync(SKILLS_DIR))
63
+ return [];
64
+ const out = [];
65
+ for (const entry of fs.readdirSync(SKILLS_DIR, { withFileTypes: true })) {
66
+ if (!entry.isDirectory())
67
+ continue;
68
+ const skillFile = path.join(SKILLS_DIR, entry.name, 'SKILL.md');
69
+ if (!fs.existsSync(skillFile))
70
+ continue;
71
+ const skill = loadOne(skillFile);
72
+ if (skill)
73
+ out.push(skill);
74
+ }
75
+ return out.sort((a, b) => a.name.localeCompare(b.name));
76
+ }
77
+ export function findSkill(name) {
78
+ const lc = name.toLowerCase();
79
+ return (listSkills().find((s) => s.name.toLowerCase() === lc) || null);
80
+ }
81
+ export function matchSkills(userInput) {
82
+ const lc = userInput.toLowerCase();
83
+ return listSkills().filter((s) => s.triggers.some((t) => t && lc.includes(t.toLowerCase())));
84
+ }
85
+ export function formatSkillForPrompt(skill) {
86
+ const desc = skill.description ? ` — ${skill.description}` : '';
87
+ return `## Skill: ${skill.name}${desc}\n${skill.body}`;
88
+ }
package/dist/ui/Chat.js CHANGED
@@ -19,6 +19,8 @@ import { bumpActivity } from './activity.js';
19
19
  import { theme } from './theme.js';
20
20
  import { getConfig, saveConfig, applyConfig } from '../core/config.js';
21
21
  import { recordModelUse } from '../core/modelUsage.js';
22
+ import { SkillsPanel } from './SkillsPanel.js';
23
+ import { listSkills, findSkill, matchSkills, ensureSkillsDir, } from '../core/skills.js';
22
24
  let nextId = 0;
23
25
  const mkItem = (kind, text) => ({ kind, text, id: nextId++ });
24
26
  const HistoryLine = ({ item }) => {
@@ -67,6 +69,7 @@ export const Chat = ({ resumedCount = 0 }) => {
67
69
  const [loading, setLoading] = useState(false);
68
70
  const [streaming, setStreaming] = useState('');
69
71
  const [overlay, setOverlay] = useState('none');
72
+ const [pendingSkill, setPendingSkill] = useState(null);
70
73
  const [permReq, setPermReq] = useState(null);
71
74
  const [currentModel, setCurrentModel] = useState(process.env.MODEL || '');
72
75
  const [agent, setAgent] = useState(() => new Agent());
@@ -105,6 +108,10 @@ export const Chat = ({ resumedCount = 0 }) => {
105
108
  if (key.ctrl && inputChar === 'o')
106
109
  setOverlay('settings');
107
110
  });
111
+ useEffect(() => { try {
112
+ ensureSkillsDir();
113
+ }
114
+ catch { } }, []);
108
115
  const flush = (kind, text) => {
109
116
  if (!text)
110
117
  return;
@@ -130,6 +137,31 @@ export const Chat = ({ resumedCount = 0 }) => {
130
137
  setOverlay('mcp');
131
138
  return;
132
139
  }
140
+ if (trimmed === '/skills') {
141
+ setInput('');
142
+ setOverlay('skills');
143
+ return;
144
+ }
145
+ if (trimmed.startsWith('/skill ')) {
146
+ setInput('');
147
+ const name = trimmed.slice(7).trim();
148
+ const sk = findSkill(name);
149
+ if (!sk) {
150
+ const all = listSkills().map((x) => x.name).join(', ') || '(none)';
151
+ setHistory((h) => [
152
+ ...h,
153
+ mkItem('error', 'unknown skill: ' + name),
154
+ mkItem('tool', 'available: ' + all),
155
+ ]);
156
+ return;
157
+ }
158
+ setPendingSkill(sk);
159
+ setHistory((h) => [
160
+ ...h,
161
+ mkItem('tool', 'skill armed: ' + sk.name + ' (applies to next message)'),
162
+ ]);
163
+ return;
164
+ }
133
165
  if (trimmed === '/debug') {
134
166
  setInput('');
135
167
  const lines = readDebug();
@@ -176,6 +208,8 @@ export const Chat = ({ resumedCount = 0 }) => {
176
208
  mkItem('tool', ' /model switch model (Ctrl+L)'),
177
209
  mkItem('tool', ' /settings open settings (Ctrl+O)'),
178
210
  mkItem('tool', ' /mcp show MCP servers and tools'),
211
+ mkItem('tool', ' /skills list skills'),
212
+ mkItem('tool', ' /skill <n> force-activate a skill for next msg'),
179
213
  mkItem('tool', ' /compact summarize history to free context'),
180
214
  mkItem('tool', ' /debug show last request to provider'),
181
215
  mkItem('tool', ' /clear clear session and permissions'),
@@ -191,7 +225,22 @@ export const Chat = ({ resumedCount = 0 }) => {
191
225
  let textBuf = '';
192
226
  let toolBuf = '';
193
227
  try {
194
- for await (const ev of agent.run(trimmed)) {
228
+ const matched = matchSkills(trimmed);
229
+ const activeSkills = [];
230
+ if (pendingSkill)
231
+ activeSkills.push(pendingSkill);
232
+ for (const m of matched) {
233
+ if (!activeSkills.find((x) => x.name === m.name))
234
+ activeSkills.push(m);
235
+ }
236
+ if (activeSkills.length > 0) {
237
+ setHistory((h) => [
238
+ ...h,
239
+ mkItem('tool', 'skills active: ' + activeSkills.map((x) => x.name).join(', ')),
240
+ ]);
241
+ }
242
+ setPendingSkill(null);
243
+ for await (const ev of agent.run(trimmed, { activeSkills })) {
195
244
  if (ev.type === 'text') {
196
245
  textBuf += ev.delta;
197
246
  setStreaming(textBuf);
@@ -255,6 +304,9 @@ export const Chat = ({ resumedCount = 0 }) => {
255
304
  if (overlay === 'mcp') {
256
305
  return (_jsx(Box, { flexDirection: "column", children: _jsx(McpPanel, { onClose: () => setOverlay('none') }) }));
257
306
  }
307
+ if (overlay === 'skills') {
308
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(SkillsPanel, { onClose: () => setOverlay('none') }) }));
309
+ }
258
310
  if (overlay === 'settings') {
259
311
  return (_jsx(Box, { flexDirection: "column", children: _jsx(Settings, { onClose: () => setOverlay('none'), onModelChange: (m) => {
260
312
  setCurrentModel(m);
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
+ import { listSkills, skillsDir } from '../core/skills.js';
5
+ import { theme } from './theme.js';
6
+ export const SkillsPanel = ({ onClose }) => {
7
+ const { stdout } = useStdout();
8
+ const width = Math.max(40, stdout?.columns ?? 60);
9
+ const inner = width - 4;
10
+ const [skills, setSkills] = useState(() => listSkills());
11
+ useInput((input, key) => {
12
+ if (key.escape)
13
+ return onClose();
14
+ if (input === 'r' || input === 'R')
15
+ setSkills(listSkills());
16
+ });
17
+ const label = 'skills';
18
+ const topPad = Math.max(1, inner - label.length - 4);
19
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: theme.accent, bold: true, children: label }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(topPad) + '╮' })] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.muted, children: ["dir: ", skillsDir()] })] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.muted, children: [skills.length, " skill", skills.length === 1 ? '' : 's', " loaded"] })] }), _jsx(Text, { color: theme.muted, children: '│ ' + '─'.repeat(Math.max(1, inner - 2)) }), skills.length === 0 ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.warning, children: "no skills found" })] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.muted, children: ["create ", skillsDir(), "/<name>/SKILL.md"] })] })] })) : (skills.map((s) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.success, children: '● ' }), _jsx(Text, { color: theme.text, bold: true, children: s.name })] }), s.description && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.muted, children: s.description.slice(0, inner - 6) })] })), s.triggers.length > 0 && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ triggers: ' }), _jsx(Text, { color: theme.muted, children: s.triggers.slice(0, 6).join(', ') })] }))] }, s.name)))), _jsx(Text, { color: theme.muted, children: '╰' + '─'.repeat(inner) + '╯' }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: " [" }), _jsx(Text, { color: theme.accent, children: "r" }), _jsx(Text, { color: theme.muted, children: "] reload \u00B7 [" }), _jsx(Text, { color: theme.accent, children: "esc" }), _jsx(Text, { color: theme.muted, children: "] close \u00B7 use " }), _jsx(Text, { color: theme.accent, children: "/skill <name>" }), _jsx(Text, { color: theme.muted, children: " to force-activate" })] })] }));
20
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blyuecode",
3
- "version": "0.3.8",
3
+ "version": "0.4.0",
4
4
  "description": "BLYUE Code — a coding agent by Lumen",
5
5
  "type": "module",
6
6
  "license": "MIT",