@yemi33/minions 0.1.86 → 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/CHANGELOG.md +16 -0
- package/engine/consolidation.js +28 -35
- package/engine/dispatch.js +1 -8
- package/engine/lifecycle.js +95 -101
- package/engine/meeting.js +72 -8
- package/engine/playbook.js +21 -11
- package/engine/shared.js +36 -5
- package/engine.js +8 -16
- package/package.json +1 -1
- package/tools/generate-pixel-art.js +134 -0
- package/tools/pixel-robot.bmp +0 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/engine/playbook.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
|
69
|
+
// All rename attempts exhausted without throw — should not happen, but clean up
|
|
48
70
|
try { fs.unlinkSync(tmp); } catch { /* cleanup */ }
|
|
49
|
-
|
|
71
|
+
throw new Error(`[safeWrite] All 5 rename attempts failed for ${p}`);
|
|
50
72
|
} catch (err) {
|
|
51
|
-
//
|
|
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
|
|
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
|
@@ -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
|