@yemi33/minions 0.1.85 → 0.1.87

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.
package/engine/meeting.js CHANGED
@@ -6,11 +6,14 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const shared = require('./shared');
9
- const { safeJson, safeWrite, safeRead, uid } = shared;
9
+ const { safeJson, safeWrite, safeRead, uid, log, ENGINE_DEFAULTS } = shared;
10
10
  const queries = require('./queries');
11
- const { getDispatch } = queries;
11
+ const { getDispatch, getConfig } = queries;
12
12
  const { renderPlaybook } = require('./playbook');
13
13
 
14
+ /** Patterns that indicate an agent returned no meaningful output */
15
+ const EMPTY_OUTPUT_PATTERNS = ['(no output)', '(no findings)', '(no response)'];
16
+
14
17
  let _engine = null;
15
18
  function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
16
19
 
@@ -43,6 +46,7 @@ function createMeeting({ title, agenda, participants }) {
43
46
  participants: participants || [],
44
47
  createdBy: 'human',
45
48
  createdAt: new Date().toISOString(),
49
+ roundStartedAt: new Date().toISOString(),
46
50
  findings: {},
47
51
  debate: {},
48
52
  conclusion: null,
@@ -175,12 +179,20 @@ function discoverMeetingWork(config) {
175
179
  * Called from runPostCompletionHooks when type === 'meeting'.
176
180
  */
177
181
  function collectMeetingFindings(meetingId, agentId, roundName, output) {
178
- const e = engine();
179
182
  const meeting = getMeeting(meetingId);
180
183
  if (!meeting) return;
181
184
 
182
185
  const { text } = shared.parseStreamJsonOutput(output, { maxTextLength: 50000 });
183
- const content = text || '(no output)';
186
+ const rawContent = (text || '').trim();
187
+
188
+ // Validate output — reject empty or placeholder responses
189
+ if (!rawContent || EMPTY_OUTPUT_PATTERNS.includes(rawContent)) {
190
+ e.log('warn', `Meeting ${meetingId}: agent ${agentId} returned empty output for ${roundName} — rejecting`);
191
+ // Don't record it — agent will be re-dispatched on next tick
192
+ saveMeeting(meeting);
193
+ return;
194
+ }
195
+ const content = rawContent;
184
196
 
185
197
  if (roundName === 'investigate') {
186
198
  meeting.findings[agentId] = { content, submittedAt: new Date().toISOString() };
@@ -204,7 +216,7 @@ function collectMeetingFindings(meetingId, agentId, roundName, output) {
204
216
  `meeting-${meetingId}-${new Date().toISOString().slice(0, 10)}.md`);
205
217
  safeWrite(inboxPath, `# Meeting Transcript: ${meeting.title}\n\n${transcript}`);
206
218
 
207
- e.log('info', `Meeting ${meetingId} completed — transcript written to inbox`);
219
+ log('info', `Meeting ${meetingId} completed — transcript written to inbox`);
208
220
  saveMeeting(meeting);
209
221
  return;
210
222
  }
@@ -221,11 +233,13 @@ function collectMeetingFindings(meetingId, agentId, roundName, output) {
221
233
  if (meeting.status === 'investigating') {
222
234
  meeting.status = 'debating';
223
235
  meeting.round = 2;
224
- e.log('info', `Meeting ${meetingId}: all findings in — advancing to debate`);
236
+ meeting.roundStartedAt = new Date().toISOString();
237
+ log('info', `Meeting ${meetingId}: all findings in — advancing to debate`);
225
238
  } else if (meeting.status === 'debating') {
226
239
  meeting.status = 'concluding';
227
240
  meeting.round = 3;
228
- e.log('info', `Meeting ${meetingId}: all debate responses in — advancing to conclusion`);
241
+ meeting.roundStartedAt = new Date().toISOString();
242
+ log('info', `Meeting ${meetingId}: all debate responses in — advancing to conclusion`);
229
243
  }
230
244
  }
231
245
 
@@ -246,6 +260,7 @@ function advanceMeetingRound(meetingId) {
246
260
  if (!meeting || meeting.status === 'completed') return null;
247
261
  if (meeting.status === 'investigating') { meeting.status = 'debating'; meeting.round = 2; }
248
262
  else if (meeting.status === 'debating') { meeting.status = 'concluding'; meeting.round = 3; }
263
+ meeting.roundStartedAt = new Date().toISOString();
249
264
  saveMeeting(meeting);
250
265
  return meeting;
251
266
  }
@@ -284,8 +299,57 @@ function deleteMeeting(id) {
284
299
  return true;
285
300
  }
286
301
 
302
+ /**
303
+ * Check for meeting rounds that have exceeded the timeout.
304
+ * Auto-advances to the next round with whatever responses were received.
305
+ * Called from engine.js tick cycle.
306
+ */
307
+ function checkMeetingTimeouts(config) {
308
+ const e = engine();
309
+ const meetings = getMeetings();
310
+ const timeout = (config.engine || {}).meetingRoundTimeout
311
+ || ENGINE_DEFAULTS.meetingRoundTimeout;
312
+
313
+ for (const meeting of meetings) {
314
+ if (meeting.status === 'completed') continue;
315
+ if (!meeting.roundStartedAt) continue;
316
+
317
+ const elapsed = Date.now() - new Date(meeting.roundStartedAt).getTime();
318
+ if (elapsed < timeout) continue;
319
+
320
+ const respondedCount = meeting.status === 'investigating'
321
+ ? Object.keys(meeting.findings || {}).length
322
+ : meeting.status === 'debating'
323
+ ? Object.keys(meeting.debate || {}).length
324
+ : 0;
325
+ const totalCount = meeting.participants.length;
326
+
327
+ if (meeting.status === 'investigating') {
328
+ e.log('warn', `Meeting ${meeting.id}: round 1 timed out after ${Math.round(elapsed / 60000)}min — ${respondedCount}/${totalCount} responded, advancing to debate`);
329
+ meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'timeout', content: `Round 1 timed out — ${respondedCount}/${totalCount} findings received`, at: new Date().toISOString() });
330
+ meeting.status = 'debating';
331
+ meeting.round = 2;
332
+ meeting.roundStartedAt = new Date().toISOString();
333
+ saveMeeting(meeting);
334
+ } else if (meeting.status === 'debating') {
335
+ e.log('warn', `Meeting ${meeting.id}: round 2 timed out after ${Math.round(elapsed / 60000)}min — ${respondedCount}/${totalCount} responded, advancing to conclusion`);
336
+ meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'timeout', content: `Round 2 timed out — ${respondedCount}/${totalCount} debate responses received`, at: new Date().toISOString() });
337
+ meeting.status = 'concluding';
338
+ meeting.round = 3;
339
+ meeting.roundStartedAt = new Date().toISOString();
340
+ saveMeeting(meeting);
341
+ } else if (meeting.status === 'concluding') {
342
+ e.log('warn', `Meeting ${meeting.id}: conclusion round timed out after ${Math.round(elapsed / 60000)}min — ending meeting without conclusion`);
343
+ meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'timeout', content: 'Conclusion round timed out — meeting ended without conclusion', at: new Date().toISOString() });
344
+ meeting.status = 'completed';
345
+ meeting.completedAt = new Date().toISOString();
346
+ saveMeeting(meeting);
347
+ }
348
+ }
349
+ }
287
350
  module.exports = {
288
351
  MEETINGS_DIR, getMeetings, getMeeting, saveMeeting, createMeeting,
289
- discoverMeetingWork, collectMeetingFindings,
352
+ discoverMeetingWork, collectMeetingFindings, checkMeetingTimeouts,
290
353
  addMeetingNote, advanceMeetingRound, endMeeting, archiveMeeting, unarchiveMeeting, deleteMeeting,
354
+ EMPTY_OUTPUT_PATTERNS,
291
355
  };
@@ -9,21 +9,15 @@ const path = require('path');
9
9
  const shared = require('./shared');
10
10
  const queries = require('./queries');
11
11
 
12
- const { safeJson, safeRead, getProjects } = shared;
12
+ const { safeJson, safeRead, getProjects, log, dateStamp } = shared;
13
13
  const { getConfig, getDispatch, getNotes, getAgentCharter, getPrs, AGENTS_DIR } = queries;
14
14
 
15
15
  const MINIONS_DIR = path.resolve(__dirname, '..');
16
16
  const PLAYBOOKS_DIR = path.join(MINIONS_DIR, 'playbooks');
17
17
 
18
- // Lazy require to avoid circular dependency with engine.js
19
- let _engine = null;
20
- function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
21
-
22
18
  // Import tempAgents from routing module
23
19
  const { tempAgents } = require('./routing');
24
20
 
25
- function dateStamp() { return new Date().toISOString().slice(0, 10); }
26
-
27
21
  // ─── Repo Host Helpers ──────────────────────────────────────────────────────
28
22
 
29
23
  function getRepoHost(project) {
@@ -123,7 +117,7 @@ function resolveTaskContext(item, config) {
123
117
  name: (a.name || id).toLowerCase(),
124
118
  }));
125
119
  const resolved = { additionalContext: '', referencedFiles: [] };
126
- const log = (...args) => engine().log(...args);
120
+
127
121
 
128
122
  // Match agent references: "ripley's plan", "dallas's pr", "lambert's output", etc.
129
123
  for (const agent of agentNames) {
@@ -194,7 +188,7 @@ function resolveTaskContext(item, config) {
194
188
  // If no specific reference was resolved but the text mentions "the plan" or "latest plan",
195
189
  // find the most recent plan
196
190
  if (!resolved.additionalContext && /\b(the|latest|last|recent)\s+plan\b/i.test(text)) {
197
- const log = (...args) => engine().log(...args);
191
+
198
192
  try {
199
193
  const plans = fs.readdirSync(path.join(MINIONS_DIR, 'plans'))
200
194
  .filter(f => f.endsWith('.md') || f.endsWith('.json'))
@@ -218,7 +212,7 @@ function renderPlaybook(type, vars) {
218
212
  const pbPath = path.join(PLAYBOOKS_DIR, `${type}.md`);
219
213
  let content;
220
214
  try { content = fs.readFileSync(pbPath, 'utf8'); } catch {
221
- engine().log('warn', `Playbook not found: ${type}`);
215
+ log('warn', `Playbook not found: ${type}`);
222
216
  return null;
223
217
  }
224
218
 
@@ -291,6 +285,22 @@ function renderPlaybook(type, vars) {
291
285
  content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(val));
292
286
  }
293
287
 
288
+ // Warn on variables that resolved to empty string
289
+ const emptyVars = Object.entries(allVars)
290
+ .filter(([, val]) => String(val) === '')
291
+ .map(([key]) => key);
292
+ if (emptyVars.length > 0) {
293
+ const msg = `Playbook "${type}": template variables resolved to empty string: ${emptyVars.join(', ')}`;
294
+ try { engine().log('warn', msg); } catch { /* engine not ready */ }
295
+ }
296
+
297
+ // Warn on any remaining unresolved {{variable}} placeholders
298
+ const unresolved = [...new Set((content.match(/\{\{(\w+)\}\}/g) || []).map(m => m.slice(2, -2)))];
299
+ if (unresolved.length > 0) {
300
+ const msg = `Playbook "${type}": unresolved template variables: ${unresolved.join(', ')}`;
301
+ try { engine().log('warn', msg); } catch { /* engine not ready */ }
302
+ }
303
+
294
304
  return content;
295
305
  }
296
306
 
@@ -338,7 +348,7 @@ function buildSystemPrompt(agentId, config, project) {
338
348
  function buildAgentContext(agentId, config, project) {
339
349
  project = project || getProjects(config)[0] || {};
340
350
  let context = '';
341
- const log = (...args) => engine().log(...args);
351
+
342
352
 
343
353
  // Agent history — last 5 tasks only (keeps it relevant, avoids 37KB dumps)
344
354
  const history = safeRead(path.join(AGENTS_DIR, agentId, 'history.md'));
package/engine/shared.js CHANGED
@@ -8,6 +8,26 @@ const path = require('path');
8
8
 
9
9
  const MINIONS_DIR = path.resolve(__dirname, '..');
10
10
  const PR_LINKS_PATH = path.join(MINIONS_DIR, 'engine', 'pr-links.json');
11
+ const LOG_PATH = path.join(__dirname, 'log.json');
12
+
13
+ // ── Timestamps & Logging ────────────────────────────────────────────────────
14
+ // Extracted from engine.js so engine/* modules can import directly without
15
+ // circular-requiring the orchestrator.
16
+
17
+ function ts() { return new Date().toISOString(); }
18
+ function logTs() { return new Date().toLocaleTimeString(); }
19
+ function dateStamp() { return new Date().toISOString().slice(0, 10); }
20
+
21
+ function log(level, msg, meta = {}) {
22
+ const entry = { timestamp: ts(), level, message: msg, ...meta };
23
+ console.log(`[${logTs()}] [${level}] ${msg}`);
24
+
25
+ let logData = safeJson(LOG_PATH) || [];
26
+ if (!Array.isArray(logData)) logData = logData.entries || [];
27
+ logData.push(entry);
28
+ if (logData.length > 2000) logData.splice(0, logData.length - 2000);
29
+ safeWrite(LOG_PATH, logData);
30
+ }
11
31
 
12
32
  // ── File I/O ─────────────────────────────────────────────────────────────────
13
33
 
@@ -41,16 +61,18 @@ function safeWrite(p, data) {
41
61
  try { const ab = new SharedArrayBuffer(4); Atomics.wait(new Int32Array(ab), 0, 0, delay); } catch { /* fallback busy-wait */ const start = Date.now(); while (Date.now() - start < delay) {} }
42
62
  continue;
43
63
  }
44
- // Final attempt failed — fall through to direct write
64
+ // Final attempt failed — throw to let caller retry
65
+ try { fs.unlinkSync(tmp); } catch { /* cleanup */ }
66
+ throw e;
45
67
  }
46
68
  }
47
- // All rename attempts failed direct write as fallback (not atomic but won't lose data)
69
+ // All rename attempts exhausted without throw should not happen, but clean up
48
70
  try { fs.unlinkSync(tmp); } catch { /* cleanup */ }
49
- fs.writeFileSync(p, content);
71
+ throw new Error(`[safeWrite] All 5 rename attempts failed for ${p}`);
50
72
  } catch (err) {
51
- // Even direct write failed log and clean up tmp
52
- console.error(`[safeWrite] FAILED to write ${p}: ${err.message}`);
73
+ // Clean up tmp if it still exists, then re-throw — never silently swallow
53
74
  try { fs.unlinkSync(tmp); } catch { /* cleanup */ }
75
+ throw err;
54
76
  }
55
77
  }
56
78
 
@@ -102,6 +124,9 @@ function mutateJsonFileLocked(filePath, mutateFn, {
102
124
  return withFileLock(lockPath, () => {
103
125
  let data = safeJson(filePath);
104
126
  if (data === null || typeof data !== 'object') data = Array.isArray(defaultValue) ? [...defaultValue] : { ...defaultValue };
127
+ // Back up last-known-good state before mutation (best-effort)
128
+ const backupPath = filePath + '.bak';
129
+ try { if (fs.existsSync(filePath)) fs.copyFileSync(filePath, backupPath); } catch { /* backup is best-effort */ }
105
130
  const next = mutateFn(data);
106
131
  const finalData = next === undefined ? data : next;
107
132
  safeWrite(filePath, finalData);
@@ -261,6 +286,7 @@ const ENGINE_DEFAULTS = {
261
286
  shutdownTimeout: 300000, // 5min — max wait for active agents during graceful shutdown
262
287
  allowTempAgents: false, // opt-in: spawn ephemeral agents when all permanent agents are busy
263
288
  autoDecompose: true, // auto-decompose implement:large items into sub-tasks
289
+ meetingRoundTimeout: 600000, // 10min per meeting round before auto-advance
264
290
  };
265
291
 
266
292
  const DEFAULT_AGENTS = {
@@ -380,6 +406,11 @@ function addPrLink(prId, itemId) {
380
406
  module.exports = {
381
407
  MINIONS_DIR,
382
408
  PR_LINKS_PATH,
409
+ LOG_PATH,
410
+ ts,
411
+ logTs,
412
+ dateStamp,
413
+ log,
383
414
  safeRead,
384
415
  safeReadDir,
385
416
  safeJson,
package/engine.js CHANGED
@@ -82,30 +82,16 @@ function validateConfig(config) {
82
82
  }
83
83
  }
84
84
 
85
- const { getProjects, projectRoot, projectStateDir, projectWorkItemsPath, projectPrPath, getAdoOrgBase, sanitizeBranch, parseSkillFrontmatter, safeReadDir } = shared;
85
+ const { getProjects, projectRoot, projectStateDir, projectWorkItemsPath, projectPrPath, getAdoOrgBase, sanitizeBranch, parseSkillFrontmatter, safeReadDir,
86
+ ts, logTs, dateStamp, log } = shared;
86
87
 
87
88
  // ─── Utilities ──────────────────────────────────────────────────────────────
88
89
 
89
- function ts() { return new Date().toISOString(); }
90
- function logTs() { return new Date().toLocaleTimeString(); }
91
- function dateStamp() { return new Date().toISOString().slice(0, 10); }
92
-
93
90
  const safeJson = shared.safeJson;
94
91
  const safeRead = shared.safeRead;
95
92
  const safeWrite = shared.safeWrite;
96
93
  const mutateJsonFileLocked = shared.mutateJsonFileLocked;
97
94
 
98
- function log(level, msg, meta = {}) {
99
- const entry = { timestamp: ts(), level, message: msg, ...meta };
100
- console.log(`[${logTs()}] [${level}] ${msg}`);
101
-
102
- let logData = safeJson(LOG_PATH) || [];
103
- if (!Array.isArray(logData)) logData = logData.entries || [];
104
- logData.push(entry);
105
- if (logData.length > 2000) logData.splice(0, logData.length - 2000);
106
- safeWrite(LOG_PATH, logData);
107
- }
108
-
109
95
  // ─── Dispatch Management (extracted to engine/dispatch.js) ───────────────────
110
96
 
111
97
  const { mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatch,
@@ -2051,6 +2037,12 @@ async function tickInner() {
2051
2037
  checkSteering(config);
2052
2038
  checkIdleThreshold(config);
2053
2039
 
2040
+ // 1b. Check for meeting round timeouts
2041
+ try {
2042
+ const { checkMeetingTimeouts } = require('./engine/meeting');
2043
+ checkMeetingTimeouts(config);
2044
+ } catch (e) { log('warn', 'check meeting timeouts: ' + e.message); }
2045
+
2054
2046
  // In stopping state, only track agent completions — skip discovery and dispatch
2055
2047
  if (control.state === 'stopping') {
2056
2048
  log('info', `Engine stopping — ${activeProcesses.size} agent(s) still active, skipping discovery/dispatch`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.85",
3
+ "version": "0.1.87",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * generate-pixel-art.js
4
+ *
5
+ * Generates a 16x16 pixel art BMP image of a little robot character.
6
+ * Uses only Node.js built-ins — no external dependencies.
7
+ *
8
+ * Usage: node tools/generate-pixel-art.js [output-path]
9
+ * Default output: tools/pixel-robot.bmp
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ // --- Color palette (R, G, B) ---
16
+ const COLORS = {
17
+ _: [0, 0, 0, 0], // transparent (white background)
18
+ B: [30, 30, 30], // black (outline)
19
+ G: [80, 80, 80], // dark gray (body shadow)
20
+ S: [160, 160, 160], // silver (body)
21
+ W: [220, 220, 220], // white (highlight)
22
+ R: [220, 50, 50], // red (antenna light / accent)
23
+ C: [60, 180, 220], // cyan (eyes)
24
+ Y: [240, 200, 60], // yellow (chest light)
25
+ D: [100, 100, 100], // mid gray (limbs)
26
+ };
27
+
28
+ // 16x16 pixel grid — a cute robot sprite
29
+ // Each character maps to COLORS above
30
+ // Row 0 = top of image
31
+ const SPRITE = [
32
+ '______RR________', // row 0: antenna light
33
+ '______BB________', // row 1: antenna stem
34
+ '____BBBBBB______', // row 2: head top
35
+ '___BWSSSSSWB____', // row 3: head
36
+ '___BCCSSWCCB____', // row 4: eyes (cyan pupils)
37
+ '___BCCSSWCCB____', // row 5: eyes
38
+ '___BSSGGSSSB____', // row 6: mouth
39
+ '____BBBBBB______', // row 7: head bottom
40
+ '___BSSYYSSB_____', // row 8: chest top + yellow light
41
+ '___DBSSSSSBD____', // row 9: chest + arms
42
+ '___DBSSSSSBD____', // row 10: chest + arms
43
+ '___DBSSSSSBD____', // row 11: chest + arms
44
+ '____BBBBBB______', // row 12: waist
45
+ '____BD__DB______', // row 13: legs
46
+ '____BD__DB______', // row 14: legs
47
+ '___BBDD_DDBB____', // row 15: feet
48
+ ];
49
+
50
+ const WIDTH = 16;
51
+ const HEIGHT = 16;
52
+
53
+ function createBMP(pixelGrid, width, height) {
54
+ // BMP with 24-bit color (no alpha)
55
+ const rowSize = Math.ceil((width * 3) / 4) * 4; // rows padded to 4-byte boundary
56
+ const pixelDataSize = rowSize * height;
57
+ const fileSize = 54 + pixelDataSize; // 14 (file header) + 40 (DIB header) + pixels
58
+
59
+ const buf = Buffer.alloc(fileSize);
60
+
61
+ // --- BMP File Header (14 bytes) ---
62
+ buf.write('BM', 0); // signature
63
+ buf.writeUInt32LE(fileSize, 2); // file size
64
+ buf.writeUInt16LE(0, 6); // reserved1
65
+ buf.writeUInt16LE(0, 8); // reserved2
66
+ buf.writeUInt32LE(54, 10); // pixel data offset
67
+
68
+ // --- DIB Header (BITMAPINFOHEADER, 40 bytes) ---
69
+ buf.writeUInt32LE(40, 14); // DIB header size
70
+ buf.writeInt32LE(width, 18); // width
71
+ buf.writeInt32LE(height, 22); // height (positive = bottom-up)
72
+ buf.writeUInt16LE(1, 26); // color planes
73
+ buf.writeUInt16LE(24, 28); // bits per pixel
74
+ buf.writeUInt32LE(0, 30); // compression (none)
75
+ buf.writeUInt32LE(pixelDataSize, 34); // image size
76
+ buf.writeInt32LE(2835, 38); // horizontal resolution (72 DPI)
77
+ buf.writeInt32LE(2835, 42); // vertical resolution (72 DPI)
78
+ buf.writeUInt32LE(0, 46); // colors in palette
79
+ buf.writeUInt32LE(0, 50); // important colors
80
+
81
+ // --- Pixel Data (bottom-up, BGR order) ---
82
+ for (let y = 0; y < height; y++) {
83
+ // BMP stores rows bottom-to-top
84
+ const srcRow = pixelGrid[height - 1 - y];
85
+ const rowOffset = 54 + y * rowSize;
86
+
87
+ for (let x = 0; x < width; x++) {
88
+ const char = srcRow[x] || '_';
89
+ const color = COLORS[char] || COLORS['_'];
90
+ const r = color[0], g = color[1], b = color[2];
91
+
92
+ // BMP uses BGR byte order
93
+ const pixelOffset = rowOffset + x * 3;
94
+ buf[pixelOffset] = b;
95
+ buf[pixelOffset + 1] = g;
96
+ buf[pixelOffset + 2] = r;
97
+ }
98
+ // Padding bytes are already 0 from Buffer.alloc
99
+ }
100
+
101
+ return buf;
102
+ }
103
+
104
+ // Parse sprite into proper grid
105
+ function parseSpriteGrid(spriteLines, width) {
106
+ return spriteLines.map(line => {
107
+ const chars = [];
108
+ for (let i = 0; i < width; i++) {
109
+ chars.push(line[i] || '_');
110
+ }
111
+ return chars;
112
+ });
113
+ }
114
+
115
+ // Fill background with white for "transparent" pixels
116
+ function fillBackground(grid) {
117
+ return grid.map(row =>
118
+ row.map(c => {
119
+ if (c === '_') return '_';
120
+ return c;
121
+ })
122
+ );
123
+ }
124
+
125
+ // Generate
126
+ const grid = parseSpriteGrid(SPRITE, WIDTH);
127
+ const bmpBuffer = createBMP(grid, WIDTH, HEIGHT);
128
+
129
+ const outputPath = process.argv[2] || path.join(__dirname, 'pixel-robot.bmp');
130
+ fs.writeFileSync(outputPath, bmpBuffer);
131
+
132
+ console.log(`✓ Pixel art robot generated: ${outputPath}`);
133
+ console.log(` Size: ${WIDTH}x${HEIGHT} pixels, ${bmpBuffer.length} bytes`);
134
+ console.log(` Format: 24-bit BMP (uncompressed)`);
Binary file