prompt-language-shell 0.9.4 → 0.9.8

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,7 +1,22 @@
1
+ import { homedir, platform } from 'os';
2
+ import { dirname, join } from 'path';
1
3
  import { DebugLevel } from '../configuration/types.js';
2
- import { createDebug } from './components.js';
3
4
  import { loadDebugSetting } from '../configuration/io.js';
4
5
  import { Palette } from './colors.js';
6
+ import { createDebug } from './components.js';
7
+ import { defaultFileSystem } from './filesystem.js';
8
+ /**
9
+ * Enum controlling what content is shown in debug prompt output
10
+ * - LLM: Exact prompt as sent to LLM (no display formatting)
11
+ * - Skills: Same content with visual separators for readability
12
+ * - Summary: Condensed view (Name, Steps, Execution only)
13
+ */
14
+ export var PromptDisplay;
15
+ (function (PromptDisplay) {
16
+ PromptDisplay["LLM"] = "llm";
17
+ PromptDisplay["Skills"] = "skills";
18
+ PromptDisplay["Summary"] = "summary";
19
+ })(PromptDisplay || (PromptDisplay = {}));
5
20
  /**
6
21
  * Debug logger for the application
7
22
  * Logs information based on the current debug level setting
@@ -11,6 +26,112 @@ let currentDebugLevel = DebugLevel.None;
11
26
  * Accumulated warnings to be displayed in the timeline
12
27
  */
13
28
  const warnings = [];
29
+ /**
30
+ * Content width for debug display (matches Debug component)
31
+ * Box width 80 - 2 borders - 4 padding = 74 chars
32
+ */
33
+ const DISPLAY_CONTENT_WIDTH = 74;
34
+ /**
35
+ * File logging configuration
36
+ */
37
+ const LOGS_DIR = join(homedir(), '.pls', 'logs');
38
+ /**
39
+ * Whether running on Windows (affects filename separators)
40
+ */
41
+ const IS_WINDOWS = platform() === 'win32';
42
+ /**
43
+ * Maximum number of letter suffixes (a-z) for unique filenames
44
+ */
45
+ const MAX_LETTER_SUFFIXES = 26;
46
+ /**
47
+ * Pad a number with leading zeros to the specified width
48
+ */
49
+ const pad = (n, width = 2) => String(n).padStart(width, '0');
50
+ /**
51
+ * Current session's log file path (null until first log entry)
52
+ */
53
+ let currentLogFile = null;
54
+ /**
55
+ * Filesystem instance for file operations (injectable for testing)
56
+ */
57
+ let fileSystem = defaultFileSystem;
58
+ /**
59
+ * Set the filesystem instance (used for testing)
60
+ */
61
+ export function setFileSystem(fs) {
62
+ fileSystem = fs;
63
+ }
64
+ /**
65
+ * Reset the session log file (used for testing)
66
+ */
67
+ export function resetSessionLog() {
68
+ currentLogFile = null;
69
+ }
70
+ /**
71
+ * Generate a timestamped log file path using local time
72
+ * Format: ~/.pls/logs/YYYY-MM-DD/HH:MM:SS.log.md (HH-MM-SS on Windows)
73
+ */
74
+ function getLogFilePath() {
75
+ const now = new Date();
76
+ const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
77
+ const separator = IS_WINDOWS ? '-' : ':';
78
+ const time = `${pad(now.getHours())}${separator}${pad(now.getMinutes())}${separator}${pad(now.getSeconds())}`;
79
+ return join(LOGS_DIR, date, `${time}.log.md`);
80
+ }
81
+ /**
82
+ * Generate a unique log file path by adding suffix if file exists
83
+ */
84
+ function getUniqueLogFilePath(basePath) {
85
+ if (!fileSystem.exists(basePath)) {
86
+ return basePath;
87
+ }
88
+ const dir = dirname(basePath);
89
+ const ext = '.log.md';
90
+ const name = basePath.slice(dir.length + 1, -ext.length);
91
+ for (let i = 0; i < MAX_LETTER_SUFFIXES; i++) {
92
+ const suffix = String.fromCharCode(97 + i); // a-z
93
+ const candidate = join(dir, `${name}-${suffix}${ext}`);
94
+ if (!fileSystem.exists(candidate)) {
95
+ return candidate;
96
+ }
97
+ }
98
+ // Fallback: use milliseconds for uniqueness (avoids overwriting)
99
+ return join(dir, `${name}-${pad(new Date().getMilliseconds(), 3)}${ext}`);
100
+ }
101
+ /**
102
+ * Initialize the session log file if not already created
103
+ */
104
+ function initializeSessionLog() {
105
+ if (currentLogFile)
106
+ return true;
107
+ try {
108
+ const basePath = getLogFilePath();
109
+ const logDir = dirname(basePath);
110
+ if (!fileSystem.exists(logDir)) {
111
+ fileSystem.createDirectory(logDir, { recursive: true });
112
+ }
113
+ const logPath = getUniqueLogFilePath(basePath);
114
+ fileSystem.writeFile(logPath, '');
115
+ currentLogFile = logPath;
116
+ return true;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ }
122
+ /**
123
+ * Append content to the current session's log file
124
+ */
125
+ function appendToLog(content) {
126
+ if (!initializeSessionLog() || !currentLogFile)
127
+ return;
128
+ try {
129
+ fileSystem.appendFile(currentLogFile, content);
130
+ }
131
+ catch {
132
+ // Silently fail - logging should not crash the app
133
+ }
134
+ }
14
135
  /**
15
136
  * Initialize the logger with the current debug level from config
16
137
  */
@@ -49,41 +170,153 @@ export function getWarnings() {
49
170
  warnings.length = 0;
50
171
  return result;
51
172
  }
173
+ /**
174
+ * Join sections with separators matching display width
175
+ */
176
+ function joinWithSeparators(sections) {
177
+ const separator = '-'.repeat(DISPLAY_CONTENT_WIDTH);
178
+ return sections.join('\n\n' + separator + '\n\n');
179
+ }
180
+ /**
181
+ * Format a single skill definition as summary markdown
182
+ */
183
+ function formatSkillSummary(skill) {
184
+ const lines = [];
185
+ lines.push(`### Name`);
186
+ lines.push(skill.name);
187
+ lines.push('');
188
+ if (skill.steps.length > 0) {
189
+ lines.push(`### Steps`);
190
+ for (const step of skill.steps) {
191
+ lines.push(`- ${step}`);
192
+ }
193
+ lines.push('');
194
+ }
195
+ if (skill.execution.length > 0) {
196
+ lines.push(`### Execution`);
197
+ for (const cmd of skill.execution) {
198
+ lines.push(`- ${cmd}`);
199
+ }
200
+ }
201
+ return lines.join('\n').trim();
202
+ }
203
+ /**
204
+ * Format skill definitions as summary for debug display
205
+ * Shows only Name, Steps, and Execution with visual separators
206
+ */
207
+ export function formatSkillsSummary(definitions) {
208
+ if (definitions.length === 0) {
209
+ return '(no skills)';
210
+ }
211
+ const header = '## Available Skills';
212
+ const skillSummaries = definitions.map(formatSkillSummary);
213
+ return joinWithSeparators([header, ...skillSummaries]);
214
+ }
215
+ /**
216
+ * Format skills section with visual separators for debug display
217
+ * Layout: Header description -> separator -> skills separated by lines
218
+ */
219
+ function formatSkillsForDisplay(formattedSkills) {
220
+ if (!formattedSkills) {
221
+ return '(no skills)';
222
+ }
223
+ // Find the header (everything before first ### Name)
224
+ const firstNameIndex = formattedSkills.search(/^###\s+Name/m);
225
+ if (firstNameIndex === -1) {
226
+ return '(no skills)';
227
+ }
228
+ const header = formattedSkills.slice(0, firstNameIndex).trim();
229
+ const skillsContent = formattedSkills.slice(firstNameIndex);
230
+ // Split by ### Name to get individual skills
231
+ const skillParts = skillsContent
232
+ .split(/(?=^###\s+Name)/m)
233
+ .map((s) => s.trim())
234
+ .filter(Boolean);
235
+ if (skillParts.length === 0) {
236
+ return '(no skills)';
237
+ }
238
+ // Join header and skills with separators
239
+ return joinWithSeparators([header, ...skillParts]);
240
+ }
241
+ /**
242
+ * Format prompt content based on the specified detail level
243
+ *
244
+ * - LLM: Returns header + base instructions + formatted skills (as sent to LLM)
245
+ * - Skills: Returns header + skills with visual separators (no base instructions)
246
+ * - Summary: Returns header + skill summaries (Name, Steps, Execution)
247
+ */
248
+ export function formatPromptContent(toolName, command, baseInstructions, formattedSkills, mode, definitions) {
249
+ switch (mode) {
250
+ case PromptDisplay.LLM: {
251
+ const header = ['', `**Tool:** ${toolName}`];
252
+ return [...header, '', baseInstructions + formattedSkills].join('\n');
253
+ }
254
+ case PromptDisplay.Skills: {
255
+ const header = `\nTool: ${toolName}\nCommand: ${command}`;
256
+ const skillsDisplay = formatSkillsForDisplay(formattedSkills);
257
+ return joinWithSeparators([header, skillsDisplay]);
258
+ }
259
+ case PromptDisplay.Summary: {
260
+ const header = `\nTool: ${toolName}\nCommand: ${command}`;
261
+ const summary = definitions
262
+ ? formatSkillsSummary(definitions)
263
+ : '(no skills)';
264
+ return joinWithSeparators([header, summary]);
265
+ }
266
+ }
267
+ }
52
268
  /**
53
269
  * Create debug component for system prompts sent to the LLM
54
- * Only creates at Verbose level
270
+ * Creates UI component at Verbose level, writes to file at Info or Verbose
271
+ *
272
+ * @param toolName - Name of the tool being invoked
273
+ * @param command - User command being processed
274
+ * @param baseInstructions - Base tool instructions (without skills)
275
+ * @param formattedSkills - Formatted skills section (as sent to LLM)
276
+ * @param definitions - Parsed skill definitions for summary display
55
277
  */
56
- export function logPrompt(toolName, command, instructions) {
278
+ export function logPrompt(toolName, command, baseInstructions, formattedSkills, definitions = []) {
279
+ // Write to file at Info or Verbose level (full LLM format)
280
+ if (currentDebugLevel !== DebugLevel.None) {
281
+ const userPrompt = `# User Command\n\n\`\`\`\n${command}\n\`\`\`\n\n`;
282
+ const fileContent = formatPromptContent(toolName, command, baseInstructions, formattedSkills, PromptDisplay.LLM);
283
+ appendToLog(userPrompt + '# System Prompt\n' + fileContent + '\n\n');
284
+ }
285
+ // Create UI component only at Verbose level
57
286
  if (currentDebugLevel !== DebugLevel.Verbose) {
58
287
  return null;
59
288
  }
60
- const content = [
61
- '',
62
- `Tool: ${toolName}`,
63
- `Command: ${command}`,
64
- '',
65
- instructions,
66
- ].join('\n');
67
- // Calculate stats for the instructions
68
- const lines = instructions.split('\n').length;
69
- const bytes = Buffer.byteLength(instructions, 'utf-8');
289
+ const content = formatPromptContent(toolName, command, baseInstructions, formattedSkills, PromptDisplay.Summary, definitions);
290
+ // Calculate stats for the full prompt
291
+ const fullPrompt = baseInstructions + formattedSkills;
292
+ const lines = fullPrompt.split('\n').length;
293
+ const bytes = Buffer.byteLength(fullPrompt, 'utf-8');
70
294
  const title = `SYSTEM PROMPT (${String(lines)} lines, ${String(bytes)} bytes)`;
71
295
  return createDebug({ title, content, color: Palette.Gray });
72
296
  }
73
297
  /**
74
298
  * Create debug component for LLM responses received
75
- * Only creates at Verbose level
299
+ * Creates UI component at Verbose level, writes to file at Info or Verbose
76
300
  */
77
301
  export function logResponse(toolName, response, durationMs) {
302
+ const jsonContent = JSON.stringify(response, null, 2);
303
+ // Write to file at Info or Verbose level (markdown format)
304
+ if (currentDebugLevel !== DebugLevel.None) {
305
+ const fileContent = [
306
+ '',
307
+ `**Tool:** ${toolName}`,
308
+ '',
309
+ '```json',
310
+ jsonContent,
311
+ '```',
312
+ ].join('\n');
313
+ appendToLog('# LLM Response\n' + fileContent + '\n\n');
314
+ }
315
+ // Create UI component only at Verbose level
78
316
  if (currentDebugLevel !== DebugLevel.Verbose) {
79
317
  return null;
80
318
  }
81
- const content = [
82
- '',
83
- `Tool: ${toolName}`,
84
- '',
85
- JSON.stringify(response, null, 2),
86
- ].join('\n');
319
+ const content = ['', `Tool: ${toolName}`, '', jsonContent].join('\n');
87
320
  const title = `LLM RESPONSE (${String(durationMs)} ms)`;
88
- return createDebug({ title, content, color: Palette.AshGray });
321
+ return createDebug({ title, content, color: Palette.LightGray });
89
322
  }
@@ -148,11 +148,14 @@ export function formatErrorMessage(error) {
148
148
  return rawMessage;
149
149
  }
150
150
  /**
151
- * Returns an execution error message with varied phrasing.
152
- * Error details are shown in the task output, so this is just a summary.
153
- * Randomly selects from variations to sound natural.
151
+ * Returns an execution error message.
152
+ * If a specific error is provided, returns it directly.
153
+ * Otherwise, returns a generic failure message with varied phrasing.
154
154
  */
155
- export function getExecutionErrorMessage(_error) {
155
+ export function getExecutionErrorMessage(error) {
156
+ if (error) {
157
+ return error;
158
+ }
156
159
  const messages = [
157
160
  'The execution failed.',
158
161
  'Execution has failed.',
@@ -0,0 +1,288 @@
1
+ import { execFile, execSync } from 'child_process';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { platform } from 'os';
4
+ import { promisify } from 'util';
5
+ // Memory monitoring constants
6
+ const MEMORY_CHECK_INTERVAL = 1000;
7
+ const DEFAULT_PAGE_SIZE = 4096;
8
+ export const SIGKILL_GRACE_PERIOD = 3000;
9
+ /**
10
+ * Get system page size in bytes.
11
+ * Computed lazily on first call, then cached at module level.
12
+ */
13
+ let cachedPageSize;
14
+ function getPageSize() {
15
+ if (cachedPageSize !== undefined) {
16
+ return cachedPageSize;
17
+ }
18
+ if (platform() === 'linux') {
19
+ try {
20
+ const output = execSync('getconf PAGESIZE', {
21
+ encoding: 'utf-8',
22
+ timeout: 1000,
23
+ }).trim();
24
+ const size = parseInt(output, 10);
25
+ if (!isNaN(size) && size > 0) {
26
+ cachedPageSize = size;
27
+ return cachedPageSize;
28
+ }
29
+ }
30
+ catch {
31
+ // Fall through to default
32
+ }
33
+ }
34
+ cachedPageSize = DEFAULT_PAGE_SIZE;
35
+ return cachedPageSize;
36
+ }
37
+ /**
38
+ * Gracefully terminate a child process with SIGTERM, escalating to SIGKILL
39
+ * after a grace period if the process doesn't terminate.
40
+ * Returns the kill timeout ID for cleanup.
41
+ */
42
+ export function killGracefully(child, gracePeriod = SIGKILL_GRACE_PERIOD) {
43
+ child.kill('SIGTERM');
44
+ return setTimeout(() => {
45
+ try {
46
+ child.kill('SIGKILL');
47
+ }
48
+ catch {
49
+ // Process already terminated
50
+ }
51
+ }, gracePeriod);
52
+ }
53
+ const execFileAsync = promisify(execFile);
54
+ /**
55
+ * Run a command asynchronously and return stdout.
56
+ * Returns undefined on error or timeout.
57
+ */
58
+ async function runCommandAsync(command, args, timeout = 1000) {
59
+ try {
60
+ const { stdout } = await execFileAsync(command, args, { timeout });
61
+ return stdout.trim();
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
67
+ /**
68
+ * Get memory usage of a process in bytes.
69
+ * Returns undefined if the process doesn't exist or memory can't be read.
70
+ * On macOS, uses async subprocess; on Linux, reads from /proc (fast).
71
+ */
72
+ async function getProcessMemoryBytes(pid) {
73
+ try {
74
+ if (platform() === 'linux') {
75
+ // Linux: Read from /proc/[pid]/statm (memory in pages)
76
+ // This is fast and effectively non-blocking for procfs
77
+ const statmPath = `/proc/${pid}/statm`;
78
+ if (!existsSync(statmPath))
79
+ return undefined;
80
+ const statm = readFileSync(statmPath, 'utf-8');
81
+ const rssPages = parseInt(statm.split(' ')[1], 10);
82
+ return rssPages * getPageSize();
83
+ }
84
+ else {
85
+ // macOS/BSD: Use ps command asynchronously
86
+ const output = await runCommandAsync('ps', [
87
+ '-o',
88
+ 'rss=',
89
+ '-p',
90
+ `${pid}`,
91
+ ]);
92
+ if (!output)
93
+ return undefined;
94
+ const rssKB = parseInt(output, 10);
95
+ if (isNaN(rssKB))
96
+ return undefined;
97
+ return rssKB * 1024;
98
+ }
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ }
104
+ /**
105
+ * Get child PIDs of a single process.
106
+ */
107
+ async function getChildPids(pid) {
108
+ try {
109
+ let output;
110
+ if (platform() === 'linux') {
111
+ output = await runCommandAsync('ps', ['-o', 'pid=', '--ppid', `${pid}`]);
112
+ }
113
+ else {
114
+ output = await runCommandAsync('pgrep', ['-P', `${pid}`]);
115
+ }
116
+ if (!output)
117
+ return [];
118
+ return output
119
+ .split('\n')
120
+ .map((p) => parseInt(p.trim(), 10))
121
+ .filter((p) => !isNaN(p) && p > 0);
122
+ }
123
+ catch {
124
+ return [];
125
+ }
126
+ }
127
+ /**
128
+ * Get all descendant PIDs of a process.
129
+ * Uses iterative BFS with parallel child lookups at each level.
130
+ */
131
+ async function getAllDescendantPids(pid) {
132
+ const visited = new Set();
133
+ const pids = [];
134
+ let currentLevel = [pid];
135
+ while (currentLevel.length > 0) {
136
+ // Add unvisited PIDs from current level
137
+ const unvisited = currentLevel.filter((p) => !visited.has(p));
138
+ for (const p of unvisited)
139
+ visited.add(p);
140
+ pids.push(...unvisited);
141
+ // Fetch children of all current level PIDs in parallel
142
+ const childArrays = await Promise.all(unvisited.map(getChildPids));
143
+ currentLevel = childArrays.flat();
144
+ }
145
+ return pids;
146
+ }
147
+ /**
148
+ * Get total memory usage of a process tree.
149
+ * Sums memory of the process and all its descendants.
150
+ */
151
+ async function getProcessTreeMemoryBytes(pid) {
152
+ try {
153
+ const allPids = await getAllDescendantPids(pid);
154
+ let totalBytes = 0;
155
+ for (const p of allPids) {
156
+ const mem = await getProcessMemoryBytes(p);
157
+ if (mem)
158
+ totalBytes += mem;
159
+ }
160
+ return totalBytes > 0 ? totalBytes : undefined;
161
+ }
162
+ catch {
163
+ return getProcessMemoryBytes(pid);
164
+ }
165
+ }
166
+ /** Monitor lifecycle state */
167
+ var MonitorState;
168
+ (function (MonitorState) {
169
+ MonitorState["Idle"] = "idle";
170
+ MonitorState["Running"] = "running";
171
+ MonitorState["Stopped"] = "stopped";
172
+ MonitorState["Killed"] = "killed";
173
+ })(MonitorState || (MonitorState = {}));
174
+ /**
175
+ * Monitors a child process memory and kills it when the limit is exceeded.
176
+ * Uses async self-scheduling to avoid blocking the event loop.
177
+ * By default monitors only the root process; set includeDescendants for tree.
178
+ */
179
+ export class MemoryMonitor {
180
+ nextCheckId;
181
+ killTimeoutId;
182
+ child;
183
+ memoryLimit;
184
+ limitBytes;
185
+ onExceeded;
186
+ state = MonitorState.Idle;
187
+ getMemoryFn;
188
+ constructor(child, memoryLimitMB, onExceeded, getMemoryFn) {
189
+ this.child = child;
190
+ this.memoryLimit = memoryLimitMB;
191
+ this.limitBytes = memoryLimitMB * 1024 * 1024;
192
+ this.onExceeded = onExceeded;
193
+ // Always monitor full process tree by default
194
+ this.getMemoryFn = getMemoryFn ?? getProcessTreeMemoryBytes;
195
+ }
196
+ /**
197
+ * Start monitoring the child process memory.
198
+ * Uses async self-scheduling loop instead of setInterval for non-blocking.
199
+ */
200
+ start() {
201
+ if (!this.child.pid)
202
+ return;
203
+ this.state = MonitorState.Running;
204
+ this.scheduleNextCheck();
205
+ }
206
+ /**
207
+ * Schedule the next memory check after the configured interval.
208
+ */
209
+ scheduleNextCheck() {
210
+ if (this.state !== MonitorState.Running)
211
+ return;
212
+ this.nextCheckId = setTimeout(() => {
213
+ void this.checkMemory();
214
+ }, MEMORY_CHECK_INTERVAL);
215
+ }
216
+ /**
217
+ * Perform async memory check and schedule next one.
218
+ */
219
+ async checkMemory() {
220
+ if (this.state !== MonitorState.Running || !this.child.pid)
221
+ return;
222
+ let memoryBytes;
223
+ try {
224
+ memoryBytes = await this.getMemoryFn(this.child.pid);
225
+ }
226
+ catch {
227
+ // Memory reading failed, schedule next check and continue
228
+ this.scheduleNextCheck();
229
+ return;
230
+ }
231
+ // Re-check after async operation - state may have changed
232
+ if (this.state !== MonitorState.Running)
233
+ return; // eslint-disable-line @typescript-eslint/no-unnecessary-condition
234
+ if (memoryBytes !== undefined && memoryBytes >= this.limitBytes) {
235
+ this.terminateProcess(memoryBytes);
236
+ }
237
+ else {
238
+ this.scheduleNextCheck();
239
+ }
240
+ }
241
+ /**
242
+ * Stop monitoring and cancel any pending timeouts.
243
+ */
244
+ stop() {
245
+ if (this.state !== MonitorState.Killed) {
246
+ this.state = MonitorState.Stopped;
247
+ }
248
+ if (this.nextCheckId) {
249
+ clearTimeout(this.nextCheckId);
250
+ this.nextCheckId = undefined;
251
+ }
252
+ if (this.killTimeoutId) {
253
+ clearTimeout(this.killTimeoutId);
254
+ this.killTimeoutId = undefined;
255
+ }
256
+ }
257
+ /**
258
+ * Terminate the child process due to memory limit exceeded.
259
+ */
260
+ terminateProcess(currentMemoryBytes) {
261
+ if (this.state === MonitorState.Killed)
262
+ return;
263
+ this.state = MonitorState.Killed;
264
+ // Clear only the next check timeout, keep killTimeoutId for cleanup
265
+ if (this.nextCheckId) {
266
+ clearTimeout(this.nextCheckId);
267
+ this.nextCheckId = undefined;
268
+ }
269
+ // Kill first, then notify - ensures termination even if callback throws
270
+ this.killTimeoutId = killGracefully(this.child);
271
+ const info = {
272
+ used: Math.ceil(currentMemoryBytes / 1024 / 1024),
273
+ limit: this.memoryLimit,
274
+ };
275
+ try {
276
+ this.onExceeded?.(info);
277
+ }
278
+ catch {
279
+ // Ignore callback errors - kill already initiated
280
+ }
281
+ }
282
+ /**
283
+ * Check if the process was killed due to memory limit.
284
+ */
285
+ wasKilledByMemoryLimit() {
286
+ return this.state === MonitorState.Killed;
287
+ }
288
+ }
@@ -1,12 +1,11 @@
1
1
  import YAML from 'yaml';
2
2
  import { displayWarning } from './logger.js';
3
3
  /**
4
- * Validate a skill without parsing it fully
4
+ * Validate extracted sections from a skill
5
5
  * Returns validation error if skill is invalid, null if valid
6
6
  * Note: Name section is optional - key from filename is used as fallback
7
7
  */
8
- export function validateSkillStructure(content, key) {
9
- const sections = extractSections(content);
8
+ function validateSections(sections, key) {
10
9
  // Use key for error reporting if name not present
11
10
  const skillName = sections.name || key;
12
11
  // Check required sections (Name is now optional)
@@ -37,6 +36,15 @@ export function validateSkillStructure(content, key) {
37
36
  }
38
37
  return null;
39
38
  }
39
+ /**
40
+ * Validate a skill without parsing it fully
41
+ * Returns validation error if skill is invalid, null if valid
42
+ * Note: Name section is optional - key from filename is used as fallback
43
+ */
44
+ export function validateSkillStructure(content, key) {
45
+ const sections = extractSections(content);
46
+ return validateSections(sections, key);
47
+ }
40
48
  /**
41
49
  * Convert kebab-case key to Title Case display name
42
50
  * Examples: "deploy-app" -> "Deploy App", "build-project-2" -> "Build Project 2"
@@ -64,8 +72,8 @@ export function parseSkillMarkdown(key, content) {
64
72
  const sections = extractSections(content);
65
73
  // Determine display name: prefer Name section, otherwise derive from key
66
74
  const displayName = sections.name || keyToDisplayName(key);
67
- // Validate the skill (Name is no longer required since we have key)
68
- const validationError = validateSkillStructure(content, key);
75
+ // Validate using already-extracted sections (avoids re-parsing)
76
+ const validationError = validateSections(sections, key);
69
77
  // For invalid skills, return minimal definition with error
70
78
  if (validationError) {
71
79
  return {
@@ -0,0 +1,14 @@
1
+ import { performance } from 'perf_hooks';
2
+ /**
3
+ * Prevent perf_hooks memory leak warning during long-running operations.
4
+ * React and Ink create performance measurements internally that accumulate
5
+ * in the global buffer. This clears them immediately and periodically.
6
+ */
7
+ export function preventPerformanceBufferOverflow(intervalMs = 60000) {
8
+ performance.clearMarks();
9
+ performance.clearMeasures();
10
+ setInterval(() => {
11
+ performance.clearMarks();
12
+ performance.clearMeasures();
13
+ }, intervalMs).unref();
14
+ }