blyuecode 0.3.7 → 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();
@@ -124,7 +136,6 @@ export class Agent {
124
136
  const before = this.messages.length;
125
137
  const sys = this.messages[0];
126
138
  const rest = this.messages.slice(1);
127
- // Build a compact transcript
128
139
  const transcript = rest
129
140
  .map((m) => {
130
141
  if (m.role === 'tool') {
package/dist/core/diff.js CHANGED
@@ -1,10 +1,8 @@
1
- // Simple LCS-based line diff. Good enough for previews.
2
1
  export function lineDiff(oldText, newText) {
3
2
  const a = oldText.length ? oldText.split('\n') : [];
4
3
  const b = newText.length ? newText.split('\n') : [];
5
4
  const m = a.length;
6
5
  const n = b.length;
7
- // LCS table
8
6
  const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
9
7
  for (let i = m - 1; i >= 0; i--) {
10
8
  for (let j = n - 1; j >= 0; j--) {
@@ -38,7 +36,6 @@ export function lineDiff(oldText, newText) {
38
36
  out.push({ type: 'add', text: b[j++] });
39
37
  return out;
40
38
  }
41
- // Compact display: only show changed regions with context
42
39
  export function compactDiff(diff, context = 2) {
43
40
  const keep = new Set();
44
41
  diff.forEach((d, idx) => {
@@ -3,8 +3,7 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  const URL = 'https://openrouter.ai/api/v1/models';
5
5
  const CACHE = path.join(os.homedir(), '.blyue', 'models-cache.json');
6
- const TTL_MS = 5 * 60 * 1000; // 5 minutes
7
- // Fallback if API is down or first-run offline
6
+ const TTL_MS = 5 * 60 * 1000;
8
7
  const FALLBACK = [
9
8
  { id: 'meta-llama/llama-3.3-70b-instruct:free', name: 'Llama 3.3 70B', context: 131072 },
10
9
  { id: 'google/gemini-2.0-flash-exp:free', name: 'Gemini 2.0 Flash', context: 1048576 },
@@ -12,7 +11,6 @@ const FALLBACK = [
12
11
  { id: 'qwen/qwen-2.5-coder-32b-instruct:free', name: 'Qwen 2.5 Coder 32B', context: 32768 },
13
12
  ];
14
13
  export async function fetchFreeModels(apiKey) {
15
- // Try cache first
16
14
  try {
17
15
  const raw = fs.readFileSync(CACHE, 'utf8');
18
16
  const cached = JSON.parse(raw);
@@ -44,7 +42,6 @@ export async function fetchFreeModels(apiKey) {
44
42
  context: m.context_length || 0,
45
43
  }))
46
44
  .sort((a, b) => b.context - a.context);
47
- // Save cache
48
45
  fs.mkdirSync(path.dirname(CACHE), { recursive: true });
49
46
  fs.writeFileSync(CACHE, JSON.stringify({ ts: Date.now(), models }));
50
47
  return models.length > 0 ? models : FALLBACK;
package/dist/core/mcp.js CHANGED
@@ -1,19 +1,5 @@
1
- // Minimal MCP client — stdio + HTTP/SSE transports, JSON-RPC 2.0.
2
- // Spec: https://spec.modelcontextprotocol.io/
3
- //
4
- // No external dependencies. Uses Node 20's global fetch for SSE.
5
- //
6
- // Usage:
7
- // const client = new McpClient(name, config);
8
- // await client.connect();
9
- // const tools = await client.listTools();
10
- // const result = await client.callTool('read_file', { path: '...' });
11
- // await client.close();
12
1
  import { spawn } from 'child_process';
13
2
  import { EventEmitter } from 'events';
14
- // ──────────────────────────────────────────────────────────────
15
- // Stdio transport — newline-delimited JSON-RPC over child stdin/stdout
16
- // ──────────────────────────────────────────────────────────────
17
3
  class StdioTransport extends EventEmitter {
18
4
  proc;
19
5
  buf = '';
@@ -39,16 +25,10 @@ class StdioTransport extends EventEmitter {
39
25
  this.emit('message', msg);
40
26
  }
41
27
  catch {
42
- // Not JSON — ignore (some servers log to stdout, which is rude
43
- // but happens; stderr is the correct channel for logs).
44
28
  }
45
29
  }
46
30
  });
47
31
  this.proc.stderr?.on('data', (chunk) => {
48
- // Servers log readiness banners and diagnostics to stderr — that's
49
- // the MCP spec's designated log channel. Never treat stderr as an
50
- // error: it would kill the in-flight initialize request. Keep the
51
- // last few KB around for debugging, but do nothing else with it.
52
32
  this.lastStderr = (this.lastStderr + chunk.toString('utf8')).slice(-4096);
53
33
  });
54
34
  this.proc.on('error', (err) => this.emit('error', err));
@@ -64,26 +44,15 @@ class StdioTransport extends EventEmitter {
64
44
  try {
65
45
  this.proc.stdin?.end();
66
46
  this.proc.kill('SIGTERM');
67
- // SIGKILL fallback if it doesn't die within 2s.
68
47
  setTimeout(() => {
69
48
  if (this.proc.exitCode === null)
70
49
  this.proc.kill('SIGKILL');
71
50
  }, 2000).unref?.();
72
51
  }
73
52
  catch {
74
- // already dead
75
53
  }
76
54
  }
77
55
  }
78
- // ──────────────────────────────────────────────────────────────
79
- // SSE transport — POST for sends, GET text/event-stream for receives
80
- //
81
- // MCP's HTTP+SSE transport (2024-11-05 spec):
82
- // 1. GET url -> text/event-stream, server sends an `endpoint` event
83
- // with the POST URL to use for requests.
84
- // 2. POST JSON-RPC requests to that endpoint; responses come back on
85
- // the SSE stream as `message` events.
86
- // ──────────────────────────────────────────────────────────────
87
56
  class SseTransport extends EventEmitter {
88
57
  cfg;
89
58
  postUrl = null;
@@ -141,8 +110,6 @@ class SseTransport extends EventEmitter {
141
110
  if (!data)
142
111
  return;
143
112
  if (event === 'endpoint') {
144
- // Server tells us where to POST. Relative URLs are resolved against
145
- // the SSE endpoint's origin.
146
113
  try {
147
114
  this.postUrl = new URL(data, this.cfg.url).toString();
148
115
  }
@@ -157,7 +124,6 @@ class SseTransport extends EventEmitter {
157
124
  this.emit('message', msg);
158
125
  }
159
126
  catch {
160
- // ignore malformed
161
127
  }
162
128
  }
163
129
  }
@@ -179,9 +145,6 @@ class SseTransport extends EventEmitter {
179
145
  this.abort?.abort();
180
146
  }
181
147
  }
182
- // ──────────────────────────────────────────────────────────────
183
- // Client
184
- // ──────────────────────────────────────────────────────────────
185
148
  export class McpClient {
186
149
  name;
187
150
  cfg;
@@ -210,7 +173,6 @@ export class McpClient {
210
173
  : new SseTransport(this.cfg);
211
174
  this.transport.on('message', (msg) => this.handleMessage(msg));
212
175
  this.transport.on('error', (err) => {
213
- // Route transport errors to any in-flight request so they don't hang.
214
176
  for (const [, p] of this.pending) {
215
177
  clearTimeout(p.timer);
216
178
  p.reject(err);
@@ -220,14 +182,12 @@ export class McpClient {
220
182
  this.transport.on('close', () => {
221
183
  this.connected = false;
222
184
  });
223
- // Handshake
224
185
  const result = (await this.request('initialize', {
225
186
  protocolVersion: '2024-11-05',
226
187
  capabilities: { tools: {} },
227
188
  clientInfo: { name: 'blyue', version: '0.2.0' },
228
189
  }));
229
190
  this.serverInfo = result?.serverInfo ?? null;
230
- // Spec requires this notification after initialize.
231
191
  this.notify('notifications/initialized', {});
232
192
  this.connected = true;
233
193
  }
@@ -256,7 +216,6 @@ export class McpClient {
256
216
  this.connected = false;
257
217
  this.cachedTools = null;
258
218
  }
259
- // ────────────────────────────────────────────
260
219
  handleMessage(msg) {
261
220
  if ('id' in msg && msg.id !== undefined) {
262
221
  const p = this.pending.get(msg.id);
@@ -272,7 +231,6 @@ export class McpClient {
272
231
  }
273
232
  return;
274
233
  }
275
- // Notifications from server (e.g. tools/list_changed) — currently ignored.
276
234
  }
277
235
  request(method, params) {
278
236
  if (!this.transport)
@@ -301,9 +259,6 @@ export class McpClient {
301
259
  this.transport.send({ jsonrpc: '2.0', method, params });
302
260
  }
303
261
  }
304
- // ──────────────────────────────────────────────────────────────
305
- // Config load / save
306
- // ──────────────────────────────────────────────────────────────
307
262
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
308
263
  import { homedir } from 'os';
309
264
  import { join, dirname } from 'path';
@@ -1,24 +1,9 @@
1
- // MCP registry — holds all connected MCP clients as a module-level
2
- // singleton so the tools layer can merge their tool defs into the LLM's
3
- // tool list without threading state through the agent.
4
- //
5
- // Lifecycle:
6
- // 1. index.tsx calls initMcpRegistry() once after config load
7
- // 2. tools.ts calls getMcpToolDefsSync() every agent-loop iteration
8
- // 3. agent calls executeTool() → falls through to executeMcpTool()
9
- // for names prefixed mcp__<server>__<tool>
10
1
  import { McpClient, loadMcpConfig, } from './mcp.js';
11
2
  import { permissions } from './permission.js';
12
3
  const servers = new Map();
13
4
  let cachedDefs = [];
14
5
  let initPromise = null;
15
6
  let initialized = false;
16
- /**
17
- * Called once on app startup, after config is loaded.
18
- * Never throws — a bad MCP server is logged and skipped so the TUI
19
- * still boots. (Failing closed here would make a typo in mcp.json
20
- * brick the whole app.)
21
- */
22
7
  export async function initMcpRegistry() {
23
8
  if (initPromise)
24
9
  return initPromise;
@@ -79,7 +64,6 @@ function rebuildDefs() {
79
64
  }
80
65
  cachedDefs = defs;
81
66
  }
82
- /** Synchronous — safe to call every iteration of the agent loop. */
83
67
  export function getMcpToolDefsSync() {
84
68
  return cachedDefs;
85
69
  }
@@ -95,10 +79,6 @@ export function parseMcpToolName(fullName) {
95
79
  return null;
96
80
  return { server: rest.slice(0, idx), tool: rest.slice(idx + 2) };
97
81
  }
98
- /**
99
- * Executes an MCP tool call. Routes through the permission broker
100
- * unless the server is marked autoAllow:true in mcp.json.
101
- */
102
82
  export async function executeMcpTool(fullName, args) {
103
83
  const parsed = parseMcpToolName(fullName);
104
84
  if (!parsed)
@@ -1,6 +1,3 @@
1
- // Tracks which models the user has picked, so the picker can surface a
2
- // RECENT section. Stored at ~/.blyue/model-usage.json. Bounded — one
3
- // entry per unique model ever picked.
4
1
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
5
2
  import { homedir } from 'os';
6
3
  import { join, dirname } from 'path';
@@ -29,7 +26,6 @@ function save(m) {
29
26
  writeFileSync(p, JSON.stringify(m, null, 2), 'utf8');
30
27
  cache = m;
31
28
  }
32
- /** Call when the user picks a model (Ctrl+L or /model). */
33
29
  export function recordModelUse(id) {
34
30
  if (!id)
35
31
  return;
@@ -38,7 +34,6 @@ export function recordModelUse(id) {
38
34
  m[id] = { count: prev.count + 1, lastUsed: Date.now() };
39
35
  save(m);
40
36
  }
41
- /** Top N most-recently-used model IDs, newest first. */
42
37
  export function getRecentModels(limit = 5) {
43
38
  const m = load();
44
39
  return Object.entries(m)
@@ -1,13 +1,9 @@
1
- // Universal model fetcher — works with any OpenAI-compatible /v1/models
2
- // endpoint: OpenAI, OpenRouter, Groq, xAI (Grok), Ollama, LM Studio,
3
- // llama.cpp, vLLM, together, deepseek, etc.
4
1
  const CACHE_TTL_MS = 5 * 60 * 1000;
5
2
  let cache = null;
6
3
  function currentBaseUrl() {
7
4
  const raw = process.env.OPENAI_BASE_URL ||
8
5
  process.env.OPENAI_API_BASE ||
9
6
  'https://api.openai.com/v1';
10
- // Strip trailing slash for consistent concat.
11
7
  return raw.replace(/\/+$/, '');
12
8
  }
13
9
  function currentApiKey() {
@@ -29,7 +25,6 @@ function providerLabel(baseUrl) {
29
25
  return 'Together';
30
26
  if (u.includes('localhost') || u.includes('127.0.0.1'))
31
27
  return 'local';
32
- // Fall back to the hostname.
33
28
  try {
34
29
  return new URL(baseUrl).host;
35
30
  }
@@ -40,10 +35,6 @@ function providerLabel(baseUrl) {
40
35
  export function currentProviderLabel() {
41
36
  return providerLabel(currentBaseUrl());
42
37
  }
43
- /**
44
- * Non-chat models we should hide from the picker. Conservative — better
45
- * to show a couple of extras than to hide something the user wants.
46
- */
47
38
  function isChatModel(id) {
48
39
  const l = id.toLowerCase();
49
40
  const block = [
@@ -74,11 +65,6 @@ function parseNumber(v) {
74
65
  const n = typeof v === 'string' ? parseFloat(v) : v;
75
66
  return Number.isFinite(n) ? n : undefined;
76
67
  }
77
- /**
78
- * Fetches the model list from the current provider. Cached for 5 minutes
79
- * per base URL, so reopening /model is instant but "new models" surface
80
- * within one cache window.
81
- */
82
68
  export async function fetchModels(opts) {
83
69
  const baseUrl = currentBaseUrl();
84
70
  const url = baseUrl + '/models';
@@ -129,8 +115,6 @@ export async function fetchModels(opts) {
129
115
  })
130
116
  .filter((m) => m !== null)
131
117
  .filter((m) => isChatModel(m.id));
132
- // Sort: free first, then alphabetical. (OpenRouter returns them in
133
- // arbitrary order; this makes the useful ones surface at the top.)
134
118
  models.sort((a, b) => {
135
119
  if (!!a.isFree !== !!b.isFree)
136
120
  return a.isFree ? -1 : 1;
@@ -142,9 +126,6 @@ export async function fetchModels(opts) {
142
126
  export function clearModelCache() {
143
127
  cache = null;
144
128
  }
145
- /**
146
- * Returns the current model ID — from config/env.
147
- */
148
129
  export function currentModelId() {
149
130
  return process.env.MODEL || '';
150
131
  }
@@ -1,12 +1,10 @@
1
1
  class PermissionBroker {
2
2
  pending = null;
3
- // Session-scoped "always allow" set (cleared on restart)
4
3
  alwaysAllow = new Set();
5
4
  listeners = new Set();
6
5
  nextId = 0;
7
6
  subscribe(fn) {
8
7
  this.listeners.add(fn);
9
- // Immediately notify with current state
10
8
  fn(this.pending?.req ?? null);
11
9
  return () => this.listeners.delete(fn);
12
10
  }
@@ -51,7 +51,6 @@ export class Provider {
51
51
  }
52
52
  throw new Error(raw);
53
53
  }
54
- // Non-streaming call (used by /compact)
55
54
  async chat(messages, tools = []) {
56
55
  const { messages: safeMessages } = this.sanitize(messages);
57
56
  try {
@@ -75,7 +74,6 @@ export class Provider {
75
74
  this.handleError(e);
76
75
  }
77
76
  }
78
- // Streaming call — yields deltas then a final summary
79
77
  async *chatStream(messages, tools) {
80
78
  const { messages: safeMessages, redactedCount } = this.sanitize(messages);
81
79
  try {
@@ -135,7 +133,6 @@ export class Provider {
135
133
  this.handleError(e);
136
134
  }
137
135
  }
138
- // Summarize helper for /compact
139
136
  async summarize(messages) {
140
137
  const res = await this.chat(messages, []);
141
138
  return res.content || '';
@@ -1,31 +1,20 @@
1
1
  const PATTERNS = [
2
- // OpenAI / OpenRouter
3
2
  [/sk-or-v1-[a-zA-Z0-9]{32,}/g, 'sk-or-v1-***REDACTED***'],
4
3
  [/sk-[a-zA-Z0-9]{20,}/g, 'sk-***REDACTED***'],
5
- // Anthropic
6
4
  [/sk-ant-[a-zA-Z0-9\-_]{20,}/g, 'sk-ant-***REDACTED***'],
7
- // GitHub
8
5
  [/ghp_[a-zA-Z0-9]{36,}/g, 'ghp_***REDACTED***'],
9
6
  [/gho_[a-zA-Z0-9]{36,}/g, 'gho_***REDACTED***'],
10
7
  [/ghs_[a-zA-Z0-9]{36,}/g, 'ghs_***REDACTED***'],
11
8
  [/github_pat_[a-zA-Z0-9_]{60,}/g, 'github_pat_***REDACTED***'],
12
- // AWS
13
9
  [/AKIA[0-9A-Z]{16}/g, 'AKIA***REDACTED***'],
14
10
  [/aws_secret_access_key\s*=\s*[A-Za-z0-9/+=]{40}/gi, 'aws_secret_access_key=***REDACTED***'],
15
- // Google
16
11
  [/AIza[0-9A-Za-z_\-]{35}/g, 'AIza***REDACTED***'],
17
- // Slack
18
12
  [/xox[baprs]-[a-zA-Z0-9-]{10,}/g, 'xox***REDACTED***'],
19
- // Stripe
20
13
  [/sk_live_[a-zA-Z0-9]{24,}/g, 'sk_live_***REDACTED***'],
21
14
  [/pk_live_[a-zA-Z0-9]{24,}/g, 'pk_live_***REDACTED***'],
22
- // JWT
23
15
  [/eyJ[a-zA-Z0-9_\-]{10,}\.eyJ[a-zA-Z0-9_\-]{10,}\.[a-zA-Z0-9_\-]{10,}/g, '***REDACTED JWT***'],
24
- // Bearer tokens
25
16
  [/Bearer\s+[a-zA-Z0-9_\-\.]{20,}/g, 'Bearer ***REDACTED***'],
26
- // PEM keys
27
17
  [/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, '-----***REDACTED KEY***-----'],
28
- // Generic env-style secrets (password=, token=, secret=)
29
18
  [/(password|passwd|pwd|secret|token|api_key|apikey)\s*[:=]\s*["']?([A-Za-z0-9_\-\.]{16,})["']?/gi,
30
19
  '$1=***REDACTED***'],
31
20
  ];
@@ -21,25 +21,17 @@ export function loadSession(cwd = process.cwd()) {
21
21
  return null;
22
22
  }
23
23
  }
24
- const MAX_SESSION_BYTES = Number(process.env.BLYUE_MAX_SESSION_BYTES) || 5 * 1024 * 1024; // 5 MB per session
25
- const MAX_DIR_BYTES = Number(process.env.BLYUE_MAX_SESSIONS_DIR_BYTES) || 50 * 1024 * 1024; // 50 MB total
26
- /**
27
- * Trim the in-memory messages array so a session file never grows past
28
- * MAX_SESSION_BYTES. Drops oldest non-system messages first, keeping a
29
- * marker so the user (and the model) knows history was clipped.
30
- */
24
+ const MAX_SESSION_BYTES = Number(process.env.BLYUE_MAX_SESSION_BYTES) || 5 * 1024 * 1024;
25
+ const MAX_DIR_BYTES = Number(process.env.BLYUE_MAX_SESSIONS_DIR_BYTES) || 50 * 1024 * 1024;
31
26
  function trimToBudget(messages) {
32
27
  const size = (arr) => Buffer.byteLength(JSON.stringify(arr, null, 2), 'utf8');
33
28
  if (size(messages) <= MAX_SESSION_BYTES)
34
29
  return messages;
35
- // Keep the most recent N messages, dropping from the front. Stop as
36
- // soon as we're under budget; always keep at least the last 6.
37
30
  let arr = messages.slice();
38
31
  while (arr.length > 6 && size(arr) > MAX_SESSION_BYTES) {
39
32
  arr.shift();
40
33
  }
41
34
  if (size(arr) > MAX_SESSION_BYTES) {
42
- // Even the tail is too big — hard-truncate the largest text fields.
43
35
  arr = arr.map((m) => {
44
36
  const c = m.content;
45
37
  if (typeof c === 'string' && c.length > 8000) {
@@ -53,10 +45,6 @@ function trimToBudget(messages) {
53
45
  ...arr,
54
46
  ];
55
47
  }
56
- /**
57
- * Evict oldest session files (never the active one) until the sessions
58
- * directory is under MAX_DIR_BYTES.
59
- */
60
48
  function pruneDirectory(activeFile) {
61
49
  if (!fs.existsSync(SESSIONS_DIR))
62
50
  return;
@@ -74,7 +62,7 @@ function pruneDirectory(activeFile) {
74
62
  }
75
63
  if (total <= MAX_DIR_BYTES)
76
64
  return;
77
- stats.sort((a, b) => a.mtime - b.mtime); // oldest first
65
+ stats.sort((a, b) => a.mtime - b.mtime);
78
66
  for (const { file, size } of stats) {
79
67
  if (total <= MAX_DIR_BYTES)
80
68
  break;
@@ -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/Banner.js CHANGED
@@ -75,7 +75,6 @@ export const Banner = () => {
75
75
  const pad = ' '.repeat(leftPad);
76
76
  const [animate] = useState(() => shouldAnimate());
77
77
  const [t, setT] = useState(0);
78
- // Slow pastel drift — 24s full cycle, matches the rest of the TUI.
79
78
  useEffect(() => {
80
79
  if (!animate)
81
80
  return;
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,10 +69,10 @@ 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());
73
- // Mascot state
74
76
  const [mascotState, setMascotState] = useState(undefined);
75
77
  const [elapsedSec, setElapsedSec] = useState(0);
76
78
  const startRef = useRef(null);
@@ -78,7 +80,6 @@ export const Chat = ({ resumedCount = 0 }) => {
78
80
  const unsub = permissions.subscribe((req) => setPermReq(req));
79
81
  return unsub;
80
82
  }, []);
81
- // Track elapsed time during loading
82
83
  useEffect(() => {
83
84
  if (!loading) {
84
85
  startRef.current = null;
@@ -93,7 +94,6 @@ export const Chat = ({ resumedCount = 0 }) => {
93
94
  }, 500);
94
95
  return () => clearInterval(t);
95
96
  }, [loading]);
96
- // Flash a temporary mascot state
97
97
  const flash = (state, ms = 1200) => {
98
98
  setMascotState(state);
99
99
  setTimeout(() => setMascotState(undefined), ms);
@@ -108,6 +108,10 @@ export const Chat = ({ resumedCount = 0 }) => {
108
108
  if (key.ctrl && inputChar === 'o')
109
109
  setOverlay('settings');
110
110
  });
111
+ useEffect(() => { try {
112
+ ensureSkillsDir();
113
+ }
114
+ catch { } }, []);
111
115
  const flush = (kind, text) => {
112
116
  if (!text)
113
117
  return;
@@ -118,7 +122,6 @@ export const Chat = ({ resumedCount = 0 }) => {
118
122
  if (!text.trim() || loading)
119
123
  return;
120
124
  const trimmed = text.trim();
121
- // ---- slash commands ----
122
125
  if (trimmed === '/model' || trimmed === '/models') {
123
126
  setInput('');
124
127
  setOverlay('model');
@@ -134,6 +137,31 @@ export const Chat = ({ resumedCount = 0 }) => {
134
137
  setOverlay('mcp');
135
138
  return;
136
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
+ }
137
165
  if (trimmed === '/debug') {
138
166
  setInput('');
139
167
  const lines = readDebug();
@@ -180,6 +208,8 @@ export const Chat = ({ resumedCount = 0 }) => {
180
208
  mkItem('tool', ' /model switch model (Ctrl+L)'),
181
209
  mkItem('tool', ' /settings open settings (Ctrl+O)'),
182
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'),
183
213
  mkItem('tool', ' /compact summarize history to free context'),
184
214
  mkItem('tool', ' /debug show last request to provider'),
185
215
  mkItem('tool', ' /clear clear session and permissions'),
@@ -187,21 +217,33 @@ export const Chat = ({ resumedCount = 0 }) => {
187
217
  ]);
188
218
  return;
189
219
  }
190
- // ---- normal run ----
191
220
  setInput('');
192
221
  setHistory((h) => [...h, mkItem('user', trimmed)]);
193
222
  setLoading(true);
194
223
  setStreaming('');
195
- // Jump animation on submit
196
224
  flash('jump', 400);
197
225
  let textBuf = '';
198
226
  let toolBuf = '';
199
227
  try {
200
- 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 })) {
201
244
  if (ev.type === 'text') {
202
245
  textBuf += ev.delta;
203
246
  setStreaming(textBuf);
204
- // Clear any tool mascot while text streams
205
247
  setMascotState(undefined);
206
248
  }
207
249
  else if (ev.type === 'tool_start') {
@@ -211,7 +253,6 @@ export const Chat = ({ resumedCount = 0 }) => {
211
253
  }
212
254
  toolBuf = '\n[Calling ' + ev.name + '...]\n';
213
255
  setStreaming(toolBuf);
214
- // React to the specific tool
215
256
  setMascotState(toolToState(ev.name));
216
257
  }
217
258
  else if (ev.type === 'compact') {
@@ -224,7 +265,6 @@ export const Chat = ({ resumedCount = 0 }) => {
224
265
  flush('tool', toolBuf + '[Result]: ' + ev.preview + '\n');
225
266
  toolBuf = '';
226
267
  setStreaming('');
227
- // Brief pulse before next step
228
268
  flash('success', 500);
229
269
  }
230
270
  }
@@ -264,6 +304,9 @@ export const Chat = ({ resumedCount = 0 }) => {
264
304
  if (overlay === 'mcp') {
265
305
  return (_jsx(Box, { flexDirection: "column", children: _jsx(McpPanel, { onClose: () => setOverlay('none') }) }));
266
306
  }
307
+ if (overlay === 'skills') {
308
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(SkillsPanel, { onClose: () => setOverlay('none') }) }));
309
+ }
267
310
  if (overlay === 'settings') {
268
311
  return (_jsx(Box, { flexDirection: "column", children: _jsx(Settings, { onClose: () => setOverlay('none'), onModelChange: (m) => {
269
312
  setCurrentModel(m);
@@ -2,10 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { Mascot } from './Mascot.js';
4
4
  export const MakerMark = ({ color, narrow }) => {
5
- // Phone / narrow: single line
6
5
  if (narrow) {
7
6
  return (_jsxs(Text, { children: [_jsx(Text, { color: color, bold: true, children: "\u2584\u2588\u2584 " }), _jsx(Text, { color: color, bold: true, children: "L U M E N" })] }));
8
7
  }
9
- // Wide: mascot on left, wordmark vertically centered
10
8
  return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Mascot, { color: color }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, justifyContent: "center", children: [_jsx(Text, { children: " " }), _jsx(Text, { children: " " }), _jsx(Text, { color: color, bold: true, children: "L U M E N" }), _jsx(Text, { children: " " }), _jsx(Text, { children: " " })] })] }));
11
9
  };
package/dist/ui/Mascot.js CHANGED
@@ -2,11 +2,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
3
  import { Box, Text } from 'ink';
4
4
  import os from 'os';
5
- // ─────────────────────────────────────────────
6
- // LUMEN mascot — gradient body, solid eyes
7
- // Light at top fades to dark at bottom
8
- // (shading = light emitting downward)
9
- // ─────────────────────────────────────────────
10
5
  const BODY_TOP = ' ▄▄▄▄▄▄▄ ';
11
6
  const BODY_LIGHT = '░░░░░░░░░';
12
7
  const BODY_FACE_A = '░ ● ● ░';
@@ -27,7 +22,6 @@ function shouldAnimate() {
27
22
  catch { }
28
23
  return true;
29
24
  }
30
- // Full mascot — 9 wide × 6 tall
31
25
  export const Mascot = ({ color }) => {
32
26
  const [pulse, setPulse] = useState(0);
33
27
  const [animate] = useState(() => shouldAnimate());
@@ -37,11 +31,8 @@ export const Mascot = ({ color }) => {
37
31
  const t = setInterval(() => setPulse((p) => (p + 1) % 3), 800);
38
32
  return () => clearInterval(t);
39
33
  }, [animate]);
40
- // Subtle pulse: alternate shade intensity on the middle row
41
34
  const mid = pulse === 0 ? BODY_MED : pulse === 1 ? '▒▒▒▒▒▒▒▒▒' : '░░▒░░▒░░';
42
35
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: color, bold: true, children: BODY_TOP }), _jsx(Text, { color: color, bold: true, children: BODY_LIGHT }), _jsx(Text, { color: color, bold: true, children: BODY_FACE_A }), _jsx(Text, { color: color, bold: true, children: BODY_FACE_B }), _jsx(Text, { color: color, bold: true, children: mid }), _jsx(Text, { color: color, bold: true, children: BODY_DARK }), _jsx(Text, { color: color, bold: true, children: BODY_BOT })] }));
43
36
  };
44
- // Compact — one line, for input bar & prompt
45
37
  export const MascotInline = ({ color }) => (_jsx(Text, { color: color, bold: true, children: "\u2584 " }));
46
- // Tiny — just the face, for very narrow terminals
47
38
  export const MascotMini = ({ color }) => (_jsx(Text, { color: color, bold: true, children: "\u2584\u2588\u2584 " }));
@@ -48,8 +48,6 @@ export function toolToState(tool) {
48
48
  return 'thinking';
49
49
  }
50
50
  const BlankRows = () => (_jsx(_Fragment, { children: Array.from({ length: TOP_ROWS }, (_, i) => (_jsx(Text, { children: " " }, i))) }));
51
- // States whose color is semantic (meaning-bearing) — these stay their
52
- // theme colors. Everything else drifts through the pastel palette.
53
51
  function isSemantic(state) {
54
52
  return (state === 'error' || state === 'denied' ||
55
53
  state === 'success' || state === 'granted' ||
@@ -60,7 +58,7 @@ function semanticColor(state) {
60
58
  return theme.error;
61
59
  if (state === 'success' || state === 'granted')
62
60
  return theme.success;
63
- return theme.warning; // permission / impatient
61
+ return theme.warning;
64
62
  }
65
63
  export const MascotPrompt = ({ state, keepAwake = false, }) => {
66
64
  const frames = FACES[state];
@@ -11,7 +11,6 @@ export const McpPanel = ({ onClose }) => {
11
11
  const [servers, setServers] = useState(() => listMcpServers());
12
12
  const [reloading, setReloading] = useState(false);
13
13
  const [msg, setMsg] = useState(null);
14
- // Refresh list on mount in case registry changed since last render.
15
14
  useEffect(() => {
16
15
  setServers(listMcpServers());
17
16
  }, []);
@@ -57,7 +57,7 @@ export const ModelPicker = ({ current, onSelect, onCancel }) => {
57
57
  const paid = [];
58
58
  for (const m of matched) {
59
59
  if (seen.has(m.id))
60
- continue; // already in recent
60
+ continue;
61
61
  if (m.isFree)
62
62
  free.push(m);
63
63
  else
@@ -86,7 +86,6 @@ export const ModelPicker = ({ current, onSelect, onCancel }) => {
86
86
  pushSection('PAID', paid);
87
87
  }
88
88
  else {
89
- // 'all' — show recent first, then free, then paid
90
89
  pushSection('RECENT', recent);
91
90
  pushSection('FREE', free);
92
91
  pushSection('PAID', paid);
@@ -26,7 +26,7 @@ function shouldAnimate() {
26
26
  export const RocketBar = ({ label, loading, width }) => {
27
27
  const [animate] = useState(() => shouldAnimate());
28
28
  const [pos, setPos] = useState(0);
29
- const hue = useSlowColor(); // shared pastel clock — matches banner + face
29
+ const hue = useSlowColor();
30
30
  const fixed = 5 + label.length;
31
31
  const midLen = Math.max(0, width - fixed);
32
32
  useEffect(() => {
@@ -17,7 +17,6 @@ export const Settings = ({ onClose, onModelChange }) => {
17
17
  const [testResult, setTestResult] = useState(null);
18
18
  const [ollamaModels, setOllamaModels] = useState([]);
19
19
  const [ollamaChecked, setOllamaChecked] = useState(false);
20
- // MCP draft state
21
20
  const [mcpCfg, setMcpCfg] = useState(() => loadMcpConfig());
22
21
  const [mcpCursor, setMcpCursor] = useState(0);
23
22
  const [mcpDraftName, setMcpDraftName] = useState('');
@@ -75,7 +74,6 @@ export const Settings = ({ onClose, onModelChange }) => {
75
74
  },
76
75
  { label: 'Close', hint: 'esc', action: onClose },
77
76
  ];
78
- // ---- Main menu ----
79
77
  useInput((_, key) => {
80
78
  if (key.escape)
81
79
  return onClose();
@@ -86,7 +84,6 @@ export const Settings = ({ onClose, onModelChange }) => {
86
84
  if (key.return)
87
85
  mainItems[cursor].action();
88
86
  }, { isActive: screen === 'main' });
89
- // ---- n8n submenu ----
90
87
  const n8nItems = [
91
88
  { label: 'URL', hint: cfg.n8nUrl || 'not set', action: () => { setDraft(cfg.n8nUrl || ''); setScreen('n8n-url'); } },
92
89
  { label: 'API key', hint: cfg.n8nApiKey ? '•••' + cfg.n8nApiKey.slice(-4) : 'not set', action: () => { setDraft(cfg.n8nApiKey || ''); setScreen('n8n-key'); } },
@@ -122,7 +119,6 @@ export const Settings = ({ onClose, onModelChange }) => {
122
119
  n8nItems[n8nCursor].action();
123
120
  }, { isActive: screen === 'n8n' });
124
121
  useInput(() => setScreen('n8n'), { isActive: screen === 'n8n-test' });
125
- // ---- MCP submenu ----
126
122
  const serverNames = Object.keys(mcpCfg.servers || {});
127
123
  const mcpItems = [
128
124
  { label: '+ Add server', hint: 'new MCP server', action: () => {
@@ -170,7 +166,6 @@ export const Settings = ({ onClose, onModelChange }) => {
170
166
  if (key.return)
171
167
  mcpItems[mcpCursor].action();
172
168
  }, { isActive: screen === 'mcp' });
173
- // ---- MCP confirm delete ----
174
169
  useInput((input, key) => {
175
170
  if (input === 'y' || input === 'Y') {
176
171
  if (mcpConfirmDel) {
@@ -188,7 +183,6 @@ export const Settings = ({ onClose, onModelChange }) => {
188
183
  setScreen('mcp');
189
184
  }
190
185
  }, { isActive: screen === 'mcp-confirm-del' });
191
- // ---- MCP add wizard: autoAllow toggle ----
192
186
  useInput((input, key) => {
193
187
  if (key.escape)
194
188
  return setScreen('mcp');
@@ -223,12 +217,10 @@ export const Settings = ({ onClose, onModelChange }) => {
223
217
  void reloadMcp(`✓ added ${mcpDraftName.trim()}`);
224
218
  setScreen('mcp');
225
219
  };
226
- // ---- Frame helpers ----
227
220
  const inner = width - 4;
228
221
  const top = (label) => (_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(Math.max(1, inner - label.length - 3)) + '╮' })] }));
229
222
  const bot = (_jsx(Text, { color: theme.muted, children: '╰' + '─'.repeat(inner) + '╯' }));
230
223
  const row = (content) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.muted, children: '│ ' }), content] }));
231
- // ---- Main screen ----
232
224
  if (screen === 'main') {
233
225
  const baseUrlIsLocal = (cfg.baseUrl || '').includes('localhost') ||
234
226
  (cfg.baseUrl || '').includes('127.0.0.1');
@@ -237,7 +229,6 @@ export const Settings = ({ onClose, onModelChange }) => {
237
229
  return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: active ? theme.accent : theme.muted, bold: active, children: active ? '❯ ' : ' ' }), _jsx(Text, { color: active ? theme.text : theme.muted, bold: active, children: it.label.padEnd(20) }), _jsx(Text, { color: theme.muted, children: it.hint })] }, it.label));
238
230
  }), bot, _jsx(Text, { color: theme.muted, children: "\u2191\u2193 move \u00B7 \u21B5 select \u00B7 esc close" })] }));
239
231
  }
240
- // ---- API key / Base URL ----
241
232
  if (screen === 'apikey' || screen === 'baseurl') {
242
233
  const isKey = screen === 'apikey';
243
234
  const title = isKey ? 'api key' : 'base url';
@@ -248,7 +239,6 @@ export const Settings = ({ onClose, onModelChange }) => {
248
239
  };
249
240
  return (_jsxs(Box, { flexDirection: "column", children: [top(title), row(_jsx(Text, { color: theme.muted, children: isKey ? 'Paste your new API key' : 'e.g. http://localhost:11434/v1' })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: value, onChange: setValue, placeholder: isKey ? 'sk-...' : 'https://...', masked: isKey, onSubmit: () => setScreen('main') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 backspace \u00B7 \u21B5 save \u00B7 esc back" })] }));
250
241
  }
251
- // ---- Model ----
252
242
  if (screen === 'model') {
253
243
  const isLocal = (cfg.baseUrl || '').includes('localhost');
254
244
  return (_jsxs(Box, { flexDirection: "column", children: [top('model'), row(_jsx(Text, { color: theme.muted, children: isLocal && ollamaModels.length > 0
@@ -258,14 +248,12 @@ export const Settings = ({ onClose, onModelChange }) => {
258
248
  setScreen('main');
259
249
  } })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 \u21B5 save \u00B7 esc back" })] }));
260
250
  }
261
- // ---- n8n submenu ----
262
251
  if (screen === 'n8n') {
263
252
  return (_jsxs(Box, { flexDirection: "column", children: [top('n8n integration'), n8nItems.map((it, i) => {
264
253
  const active = i === n8nCursor;
265
254
  return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: active ? theme.accent : theme.muted, bold: active, children: active ? '❯ ' : ' ' }), _jsx(Text, { color: active ? theme.text : theme.muted, bold: active, children: it.label.padEnd(20) }), _jsx(Text, { color: theme.muted, children: it.hint })] }, it.label));
266
255
  }), bot, _jsx(Text, { color: theme.muted, children: "\u2191\u2193 move \u00B7 \u21B5 select \u00B7 esc back" })] }));
267
256
  }
268
- // ---- n8n url/key ----
269
257
  if (screen === 'n8n-url' || screen === 'n8n-key') {
270
258
  const isKey = screen === 'n8n-key';
271
259
  const title = isKey ? 'n8n api key' : 'n8n url';
@@ -276,34 +264,27 @@ export const Settings = ({ onClose, onModelChange }) => {
276
264
  };
277
265
  return (_jsxs(Box, { flexDirection: "column", children: [top(title), row(_jsx(Text, { color: theme.muted, children: isKey ? 'Settings → n8n API → create key' : 'e.g. http://localhost:5678' })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: value, onChange: setValue, placeholder: isKey ? 'n8n_api_...' : 'http://localhost:5678', masked: isKey, onSubmit: () => setScreen('n8n') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 backspace \u00B7 \u21B5 save \u00B7 esc back" })] }));
278
266
  }
279
- // ---- n8n test ----
280
267
  if (screen === 'n8n-test') {
281
268
  return (_jsxs(Box, { flexDirection: "column", children: [top('n8n test'), row(_jsx(Text, { color: testResult?.startsWith('✓') ? theme.success : theme.muted, children: testResult || 'testing…' })), bot, _jsx(Text, { color: theme.muted, children: "press any key to go back" })] }));
282
269
  }
283
- // ---- MCP submenu ----
284
270
  if (screen === 'mcp') {
285
271
  return (_jsxs(Box, { flexDirection: "column", children: [top('mcp servers'), row(_jsx(Text, { color: theme.muted, children: "config: ~/.blyue/mcp.json" })), row(_jsxs(Text, { color: theme.muted, children: [serverNames.length, " server", serverNames.length === 1 ? '' : 's', mcpStatus ? ' · ' : '', mcpStatus && (_jsx(Text, { color: mcpStatus.startsWith('✗') ? theme.error : theme.success, children: mcpStatus }))] })), _jsx(Text, { color: theme.muted, children: '│ ' + '─'.repeat(Math.max(1, inner - 2)) }), mcpItems.map((it, i) => {
286
272
  const active = i === mcpCursor;
287
273
  return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: active ? theme.accent : theme.muted, bold: active, children: active ? '❯ ' : ' ' }), _jsx(Text, { color: active ? theme.text : theme.muted, bold: active, children: it.label.padEnd(22) }), _jsx(Text, { color: theme.muted, children: it.hint })] }, it.label));
288
274
  }), bot, _jsx(Text, { color: theme.muted, children: "\u2191\u2193 move \u00B7 \u21B5 select \u00B7 [d] delete \u00B7 esc back" })] }));
289
275
  }
290
- // ---- MCP confirm delete ----
291
276
  if (screen === 'mcp-confirm-del') {
292
277
  return (_jsxs(Box, { flexDirection: "column", children: [top('delete server'), row(_jsxs(Text, { color: theme.warning, children: ["Delete MCP server \"", mcpConfirmDel, "\"?"] })), row(_jsx(Text, { color: theme.muted, children: "This removes it from mcp.json." })), bot, _jsxs(Text, { children: [_jsx(Text, { color: theme.success, bold: true, children: " [y]" }), _jsx(Text, { color: theme.muted, children: " yes " }), _jsx(Text, { color: theme.error, bold: true, children: "[n]" }), _jsx(Text, { color: theme.muted, children: " no" })] })] }));
293
278
  }
294
- // ---- MCP add wizard: name ----
295
279
  if (screen === 'mcp-add-name') {
296
280
  return (_jsxs(Box, { flexDirection: "column", children: [top('add mcp · step 1/4'), row(_jsx(Text, { color: theme.muted, children: "Name (unique, e.g. \"filesystem\")" })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: mcpDraftName, onChange: setMcpDraftName, placeholder: "filesystem", onSubmit: () => setScreen('mcp-add-command') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 \u21B5 next \u00B7 esc cancel" })] }));
297
281
  }
298
- // ---- MCP add wizard: command ----
299
282
  if (screen === 'mcp-add-command') {
300
283
  return (_jsxs(Box, { flexDirection: "column", children: [top('add mcp · step 2/4'), row(_jsx(Text, { color: theme.muted, children: "Command (binary to run)" })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: mcpDraftCommand, onChange: setMcpDraftCommand, placeholder: "npx", onSubmit: () => setScreen('mcp-add-args') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 \u21B5 next \u00B7 esc cancel" })] }));
301
284
  }
302
- // ---- MCP add wizard: args ----
303
285
  if (screen === 'mcp-add-args') {
304
286
  return (_jsxs(Box, { flexDirection: "column", children: [top('add mcp · step 3/4'), row(_jsx(Text, { color: theme.muted, children: "Args (space-separated, can be empty)" })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: mcpDraftArgs, onChange: setMcpDraftArgs, placeholder: "-y @modelcontextprotocol/server-filesystem /home/user", onSubmit: () => setScreen('mcp-add-autollw') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 \u21B5 next \u00B7 esc cancel" })] }));
305
287
  }
306
- // ---- MCP add wizard: autoAllow ----
307
288
  if (screen === 'mcp-add-autollw') {
308
289
  return (_jsxs(Box, { flexDirection: "column", children: [top('add mcp · step 4/4'), row(_jsx(Text, { color: theme.muted, children: "Auto-allow calls to this server (skip permission prompts)?" })), row(_jsx(Text, { color: theme.warning, children: "Only enable for servers you fully trust." })), bot, _jsxs(Text, { children: [_jsx(Text, { color: theme.success, bold: true, children: " [y]" }), _jsx(Text, { color: theme.muted, children: " yes " }), _jsx(Text, { color: theme.error, bold: true, children: "[n]" }), _jsx(Text, { color: theme.muted, children: " no (recommended)" })] })] }));
309
290
  }
package/dist/ui/Setup.js CHANGED
@@ -91,7 +91,6 @@ export const Setup = ({ onDone }) => {
91
91
  setStep('provider');
92
92
  }
93
93
  };
94
- // Header: single line hint
95
94
  const Hint = ({ children }) => (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.muted, children: children }) }));
96
95
  if (step === 'provider') {
97
96
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Hint, { children: "Select a provider \u2191\u2193 Enter" }), PROVIDERS.map((p, i) => {
@@ -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
+ };
@@ -2,8 +2,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
3
  import { Box, Text } from 'ink';
4
4
  import { theme } from './theme.js';
5
- // Two rows, always exactly two rows — the mascot container reserves this
6
- // space whether a z is visible or not, so Ink never reflows.
7
5
  const FRAMES = [
8
6
  [' ', ' '],
9
7
  [' ', ' z '],
@@ -6,7 +6,6 @@ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '
6
6
  export const Spinner = ({ label = 'Thinking' }) => {
7
7
  const [i, setI] = useState(0);
8
8
  useEffect(() => {
9
- // 200ms instead of 90ms → 5 fps not 11 fps
10
9
  const t = setInterval(() => setI((v) => (v + 1) % FRAMES.length), 200);
11
10
  return () => clearInterval(t);
12
11
  }, []);
@@ -1,9 +1,5 @@
1
- // Tiny global activity bus — avoids prop-drilling an `activeSignal` number
2
- // through every component. Chat.tsx calls bumpActivity() from useInput;
3
- // MascotPrompt subscribes via useIdle().
4
1
  let counter = 0;
5
2
  const listeners = new Set();
6
- /** Call this from any real user input handler (keystroke, submit). */
7
3
  export function bumpActivity() {
8
4
  counter = (counter + 1) % Number.MAX_SAFE_INTEGER;
9
5
  for (const l of listeners)
@@ -1,4 +1,3 @@
1
- // HSL → hex, no dependencies
2
1
  function hslToHex(h, s, l) {
3
2
  h = ((h % 360) + 360) % 360;
4
3
  s /= 100;
@@ -11,9 +10,7 @@ function hslToHex(h, s, l) {
11
10
  };
12
11
  return `#${f(0)}${f(8)}${f(4)}`;
13
12
  }
14
- /** Returns a hex color at position t (0..1) along a soft palette loop. */
15
13
  export function hueAt(t, lightness = 65, saturation = 70) {
16
- // Weighted loop: cyan → blue → purple → pink → cyan
17
14
  const hue = 180 + Math.sin(t * Math.PI * 2) * 120;
18
15
  return hslToHex(hue, saturation, lightness);
19
16
  }
package/dist/ui/pastel.js CHANGED
@@ -1,6 +1,3 @@
1
- // Pastel/faded palette — Claude-Code-inspired soft muting.
2
- // All colors sit at ~76% lightness / ~48% saturation, so they read as
3
- // "faded" on both dark and light terminals.
4
1
  function hslToHex(h, s, l) {
5
2
  s /= 100;
6
3
  l /= 100;
@@ -13,18 +10,12 @@ function hslToHex(h, s, l) {
13
10
  const toHex = (x) => x.toString(16).padStart(2, '0');
14
11
  return '#' + toHex(f(0)) + toHex(f(8)) + toHex(f(4));
15
12
  }
16
- /** Full-hue pastel cycle. t in [0, 1). */
17
13
  export function pastelAt(t) {
18
14
  const h = (((t % 1) + 1) % 1) * 360;
19
15
  return hslToHex(h, 48, 76);
20
16
  }
21
- /**
22
- * Warm-only variant — rose → peach → gold → sage → back. If the full
23
- * wheel feels too rainbow-y for your taste, swap pastelAt -> pastelWarmAt
24
- * in the four call sites below.
25
- */
26
17
  export function pastelWarmAt(t) {
27
18
  const t2 = ((t % 1) + 1) % 1;
28
- const h = (320 + t2 * 110) % 360; // 320° → 70°, wraps through warm
19
+ const h = (320 + t2 * 110) % 360;
29
20
  return hslToHex(h, 52, 76);
30
21
  }
@@ -1,6 +1,4 @@
1
- // A single module-level tick shared by every component that wants to
2
- // fade in sync. One interval, N subscribers, no per-component drift.
3
- const PERIOD_MS = 24_000; // full pastel cycle
1
+ const PERIOD_MS = 24_000;
4
2
  const TICK_MS = 120;
5
3
  let t = 0;
6
4
  const listeners = new Set();
@@ -13,7 +11,6 @@ function start() {
13
11
  for (const l of listeners)
14
12
  l();
15
13
  }, TICK_MS);
16
- // Don't keep the Node process alive just to animate colors.
17
14
  const anyInt = interval;
18
15
  anyInt.unref?.();
19
16
  }
@@ -27,11 +27,6 @@ export function useIdle({ drowsyAfterMs = 120_000, sleepAfterMs = 300_000, disab
27
27
  }, [active, drowsyAfterMs, sleepAfterMs, disabled]);
28
28
  return stage;
29
29
  }
30
- /**
31
- * Idle thresholds.
32
- * BLYUE_FAST_IDLE=1 -> 3s / 6s (dev testing only)
33
- * default -> 2 min drowsy / 5 min sleep
34
- */
35
30
  export function defaultIdleThresholds() {
36
31
  if (process.env.BLYUE_FAST_IDLE === '1') {
37
32
  return { drowsyAfterMs: 3_000, sleepAfterMs: 6_000 };
@@ -1,10 +1,6 @@
1
1
  import { useSyncExternalStore } from 'react';
2
2
  import { subscribeSlowTick, getSlowTick } from './slowTick.js';
3
3
  import { pastelAt, pastelWarmAt } from './pastel.js';
4
- /**
5
- * Returns a pastel hex color that drifts slowly and stays in sync with
6
- * every other component that uses this hook.
7
- */
8
4
  export function useSlowColor(warm = false) {
9
5
  const t = useSyncExternalStore(subscribeSlowTick, getSlowTick, getSlowTick);
10
6
  return warm ? pastelWarmAt(t) : pastelAt(t);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blyuecode",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
4
4
  "description": "BLYUE Code — a coding agent by Lumen",
5
5
  "type": "module",
6
6
  "license": "MIT",