@yemi33/minions 0.1.56 → 0.1.58

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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.58 (2026-03-30)
4
+
5
+ ### Engine
6
+ - engine.js
7
+
8
+ ### Dashboard
9
+ - dashboard.js
10
+
11
+ ### Other
12
+ - test/unit.test.js
13
+
14
+ ## 0.1.57 (2026-03-30)
15
+
16
+ ### Engine
17
+ - engine.js
18
+
3
19
  ## 0.1.56 (2026-03-30)
4
20
 
5
21
  ### Other
package/dashboard.js CHANGED
@@ -635,6 +635,17 @@ function readBody(req) {
635
635
  });
636
636
  }
637
637
 
638
+ const _rateLimits = new Map();
639
+ function checkRateLimit(key, maxPerMinute) {
640
+ const now = Date.now();
641
+ const entries = _rateLimits.get(key) || [];
642
+ const recent = entries.filter(t => now - t < 60000);
643
+ if (recent.length >= maxPerMinute) return true;
644
+ recent.push(now);
645
+ _rateLimits.set(key, recent);
646
+ return false;
647
+ }
648
+
638
649
  function jsonReply(res, code, data, req) {
639
650
  res.setHeader('Content-Type', 'application/json');
640
651
  res.setHeader('Access-Control-Allow-Origin', '*');
@@ -1270,7 +1281,6 @@ const server = http.createServer(async (req, res) => {
1270
1281
  'Content-Type': 'text/event-stream',
1271
1282
  'Cache-Control': 'no-cache',
1272
1283
  'Connection': 'keep-alive',
1273
- 'Access-Control-Allow-Origin': '*',
1274
1284
  });
1275
1285
 
1276
1286
  // Send initial content
@@ -1387,7 +1397,7 @@ const server = http.createServer(async (req, res) => {
1387
1397
  const cat = match[1];
1388
1398
  const file = decodeURIComponent(match[2]);
1389
1399
  // Prevent path traversal
1390
- if (file.includes('..') || file.includes('/') || file.includes('\\')) {
1400
+ if (file.includes('..') || file.includes('\0') || file.includes('/') || file.includes('\\')) {
1391
1401
  return jsonReply(res, 400, { error: 'invalid file name' });
1392
1402
  }
1393
1403
  const content = safeRead(path.join(MINIONS_DIR, 'knowledge', cat, file));
@@ -1618,7 +1628,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1618
1628
 
1619
1629
  async function handlePlansArchiveRead(req, res, match) {
1620
1630
  const file = decodeURIComponent(match[1]);
1621
- if (file.includes('..')) return jsonReply(res, 400, { error: 'invalid' });
1631
+ if (file.includes('..') || file.includes('\0')) return jsonReply(res, 400, { error: 'invalid' });
1622
1632
  // Check prd/archive/ first for .json, then plans/archive/ for .md
1623
1633
  const archiveDir = file.endsWith('.json') ? path.join(PRD_DIR, 'archive') : path.join(PLANS_DIR, 'archive');
1624
1634
  let content = safeRead(path.join(archiveDir, file));
@@ -1634,7 +1644,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1634
1644
 
1635
1645
  async function handlePlansRead(req, res, match) {
1636
1646
  const file = decodeURIComponent(match[1]);
1637
- if (file.includes('..') || file.includes('/') || file.includes('\\')) return jsonReply(res, 400, { error: 'invalid' });
1647
+ if (file.includes('..') || file.includes('\0') || file.includes('/') || file.includes('\\')) return jsonReply(res, 400, { error: 'invalid' });
1638
1648
  let content = safeRead(resolvePlanPath(file));
1639
1649
  // Fallback: check all directories (prd/, plans/, guides/, archives)
1640
1650
  if (!content) content = safeRead(path.join(PRD_DIR, file));
@@ -1801,7 +1811,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1801
1811
  try {
1802
1812
  const body = await readBody(req);
1803
1813
  if (!body.file) return jsonReply(res, 400, { error: 'file is required' });
1804
- if (body.file.includes('..')) return jsonReply(res, 400, { error: 'invalid file path' });
1814
+ if (body.file.includes('..') || body.file.includes('\0')) return jsonReply(res, 400, { error: 'invalid file path' });
1805
1815
 
1806
1816
  const prdPath = path.join(PRD_DIR, body.file);
1807
1817
  const plan = safeJson(prdPath);
@@ -1865,6 +1875,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1865
1875
  }
1866
1876
 
1867
1877
  async function handlePlansExecute(req, res) {
1878
+ if (checkRateLimit('plans-execute', 5)) return jsonReply(res, 429, { error: 'Rate limited — max 5 requests/minute' });
1868
1879
  try {
1869
1880
  const body = await readBody(req);
1870
1881
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
@@ -1975,7 +1986,7 @@ If nothing to do, return: { "duplicates": [], "reclassify": [], "remove": [] }`;
1975
1986
  try {
1976
1987
  const body = await readBody(req);
1977
1988
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
1978
- if (body.file.includes('..') || body.file.includes('/') || body.file.includes('\\')) {
1989
+ if (body.file.includes('..') || body.file.includes('\0') || body.file.includes('/') || body.file.includes('\\')) {
1979
1990
  return jsonReply(res, 400, { error: 'invalid filename' });
1980
1991
  }
1981
1992
  const planPath = resolvePlanPath(body.file);
@@ -2345,6 +2356,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2345
2356
  const body = await readBody(req);
2346
2357
  const { name } = body;
2347
2358
  if (!name) return jsonReply(res, 400, { error: 'name required' });
2359
+ if (name.includes('..') || name.includes('\0')) return jsonReply(res, 400, { error: 'Invalid file name' });
2348
2360
 
2349
2361
  const inboxPath = path.join(MINIONS_DIR, 'notes', 'inbox', name);
2350
2362
  const content = safeRead(inboxPath);
@@ -2384,6 +2396,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2384
2396
  const body = await readBody(req);
2385
2397
  const { name, category } = body;
2386
2398
  if (!name) return jsonReply(res, 400, { error: 'name required' });
2399
+ if (name.includes('..') || name.includes('\0')) return jsonReply(res, 400, { error: 'Invalid file name' });
2387
2400
  if (!category || !shared.KB_CATEGORIES.includes(category)) {
2388
2401
  return jsonReply(res, 400, { error: 'category required: ' + shared.KB_CATEGORIES.join(', ') });
2389
2402
  }
@@ -2420,7 +2433,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2420
2433
  try {
2421
2434
  const body = await readBody(req);
2422
2435
  const { name } = body;
2423
- if (!name || name.includes('..') || name.includes('/') || name.includes('\\')) {
2436
+ if (!name || name.includes('..') || name.includes('\0') || name.includes('/') || name.includes('\\')) {
2424
2437
  return jsonReply(res, 400, { error: 'invalid name' });
2425
2438
  }
2426
2439
  const filePath = path.join(MINIONS_DIR, 'notes', 'inbox', name);
@@ -2446,7 +2459,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2446
2459
  try {
2447
2460
  const body = await readBody(req);
2448
2461
  const { name } = body;
2449
- if (!name || name.includes('..') || name.includes('/') || name.includes('\\')) {
2462
+ if (!name || name.includes('..') || name.includes('\0') || name.includes('/') || name.includes('\\')) {
2450
2463
  return jsonReply(res, 400, { error: 'invalid name' });
2451
2464
  }
2452
2465
  const filePath = path.join(MINIONS_DIR, 'notes', 'inbox', name);
@@ -2460,7 +2473,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2460
2473
  const params = new URL(req.url, 'http://localhost').searchParams;
2461
2474
  const file = params.get('file');
2462
2475
  const dir = params.get('dir');
2463
- if (!file || file.includes('..')) { res.statusCode = 400; res.end('Invalid file'); return; }
2476
+ if (!file || file.includes('..') || file.includes('\0')) { res.statusCode = 400; res.end('Invalid file'); return; }
2464
2477
 
2465
2478
  let content = '';
2466
2479
  if (dir) {
@@ -2616,6 +2629,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
2616
2629
  }
2617
2630
 
2618
2631
  async function handleCommandCenter(req, res) {
2632
+ if (checkRateLimit('command-center', 10)) return jsonReply(res, 429, { error: 'Rate limited — max 10 requests/minute' });
2619
2633
  try {
2620
2634
  const body = await readBody(req);
2621
2635
  if (!body.message) return jsonReply(res, 400, { error: 'message required' });
@@ -3103,19 +3117,34 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3103
3117
  // ── Route Dispatcher ────────────────────────────────────────────────────────
3104
3118
 
3105
3119
  const pathname = req.url.split('?')[0];
3120
+ const _reqStart = Date.now();
3106
3121
  for (const route of ROUTES) {
3107
3122
  if (route.method !== req.method) continue;
3108
3123
  if (typeof route.path === 'string') {
3109
3124
  // For /api/skill, match with query string prefix since it has no fixed path variant
3110
3125
  if (route.path === '/api/skill') {
3111
3126
  if (!req.url.startsWith('/api/skill?') && req.url !== '/api/skill') continue;
3112
- return await route.handler(req, res, {});
3127
+ const _result = await route.handler(req, res, {});
3128
+ if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream')) {
3129
+ console.log(` ${req.method} ${pathname} ${Date.now() - _reqStart}ms`);
3130
+ }
3131
+ return _result;
3113
3132
  }
3114
3133
  if (pathname !== route.path) continue;
3115
- return await route.handler(req, res, {});
3134
+ const _result = await route.handler(req, res, {});
3135
+ if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream')) {
3136
+ console.log(` ${req.method} ${pathname} ${Date.now() - _reqStart}ms`);
3137
+ }
3138
+ return _result;
3116
3139
  }
3117
3140
  const m = pathname.match(route.path);
3118
- if (m) return await route.handler(req, res, m);
3141
+ if (m) {
3142
+ const _result = await route.handler(req, res, m);
3143
+ if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream')) {
3144
+ console.log(` ${req.method} ${pathname} ${Date.now() - _reqStart}ms`);
3145
+ }
3146
+ return _result;
3147
+ }
3119
3148
  }
3120
3149
 
3121
3150
  // Serve dashboard HTML with gzip + caching
package/engine.js CHANGED
@@ -102,7 +102,7 @@ function log(level, msg, meta = {}) {
102
102
  let logData = safeJson(LOG_PATH) || [];
103
103
  if (!Array.isArray(logData)) logData = logData.entries || [];
104
104
  logData.push(entry);
105
- if (logData.length > 500) logData.splice(0, logData.length - 500);
105
+ if (logData.length > 2000) logData.splice(0, logData.length - 2000);
106
106
  safeWrite(LOG_PATH, logData);
107
107
  }
108
108
 
@@ -356,16 +356,23 @@ function renderPlaybook(type, vars) {
356
356
  return null;
357
357
  }
358
358
 
359
- // Inject pinned context (always visible to agents)
359
+ // Inject pinned context (always visible to agents) — capped at 4KB
360
360
  let pinnedContent = '';
361
361
  try { pinnedContent = fs.readFileSync(path.join(MINIONS_DIR, 'pinned.md'), 'utf8'); } catch {}
362
362
  if (pinnedContent) {
363
+ if (pinnedContent.length > 4096) pinnedContent = pinnedContent.slice(0, 4096) + '\n\n_...pinned.md truncated (read full file if needed)_';
363
364
  content += '\n\n---\n\n## Pinned Context (CRITICAL — READ FIRST)\n\n' + pinnedContent;
364
365
  }
365
366
 
366
- // Inject team notes context
367
- const notes = getNotes();
367
+ // Inject team notes (single injection point — not in buildAgentContext) — capped at 8KB
368
+ let notes = getNotes();
368
369
  if (notes) {
370
+ if (notes.length > 8192) {
371
+ const sections = notes.split(/(?=^### )/m);
372
+ const recent = sections.slice(-10).join('');
373
+ notes = recent.length > 8192 ? recent.slice(0, 8192) + '\n\n_...notes truncated_' : recent;
374
+ notes += '\n\n_' + Math.max(0, sections.length - 10) + ' older entries in `notes.md` — Read if needed._';
375
+ }
369
376
  content += '\n\n---\n\n## Team Notes (MUST READ)\n\n' + notes;
370
377
  }
371
378
 
@@ -621,18 +628,8 @@ function buildAgentContext(agentId, config, project) {
621
628
  context += `## Pending Work Queue (${(dispatch.pending || []).length} items)\n\n${pendingItems.join('\n')}${(dispatch.pending || []).length > 10 ? '\n- ... and ' + ((dispatch.pending || []).length - 10) + ' more' : ''}\n\n`;
622
629
  }
623
630
 
624
- // Team notes last 5 sections only (recent learnings, not the full history)
625
- const notes = getNotes();
626
- if (notes) {
627
- const sections = notes.split(/(?=^### )/m);
628
- const header = sections[0] && !sections[0].startsWith('### ') ? sections.shift() : '';
629
- if (sections.length > 5) {
630
- const recent = sections.slice(-5).join('');
631
- context += `## Team Notes (last 5 of ${sections.length})\n\n${recent}\n\n_${sections.length - 5} older entries in \`notes.md\` — Read if needed._\n\n`;
632
- } else {
633
- context += `## Team Notes\n\n${notes}\n\n`;
634
- }
635
- }
631
+ // Team notes injected via renderPlaybook (single injection point, with truncation)
632
+ // Not duplicated here to avoid double-injection and token waste
636
633
 
637
634
  return context;
638
635
  }
@@ -2815,7 +2812,7 @@ function discoverFromWorkItems(config, project) {
2815
2812
  try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
2816
2813
 
2817
2814
  // Inject references and acceptance criteria
2818
- const refs = (item.references || []).map(r =>
2815
+ const refs = (item.references || []).filter(r => r && r.url).map(r =>
2819
2816
  '- [' + (r.title || r.url) + '](' + r.url + ')' + (r.type ? ' (' + r.type + ')' : '')
2820
2817
  ).join('\n');
2821
2818
  vars.references = refs ? '## References\n\n' + refs : '';
@@ -3110,7 +3107,7 @@ function discoverCentralWorkItems(config) {
3110
3107
  };
3111
3108
 
3112
3109
  // Inject references and acceptance criteria
3113
- const fanRefs = (item.references || []).map(r =>
3110
+ const fanRefs = (item.references || []).filter(r => r && r.url).map(r =>
3114
3111
  '- [' + (r.title || r.url) + '](' + r.url + ')' + (r.type ? ' (' + r.type + ')' : '')
3115
3112
  ).join('\n');
3116
3113
  vars.references = fanRefs ? '## References\n\n' + fanRefs : '';
@@ -3185,7 +3182,7 @@ function discoverCentralWorkItems(config) {
3185
3182
  try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
3186
3183
 
3187
3184
  // Inject references and acceptance criteria
3188
- const normRefs = (item.references || []).map(r =>
3185
+ const normRefs = (item.references || []).filter(r => r && r.url).map(r =>
3189
3186
  '- [' + (r.title || r.url) + '](' + r.url + ')' + (r.type ? ' (' + r.type + ')' : '')
3190
3187
  ).join('\n');
3191
3188
  vars.references = normRefs ? '## References\n\n' + normRefs : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.56",
3
+ "version": "0.1.58",
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"