@yemi33/minions 0.1.2152 → 0.1.2154

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.
@@ -214,6 +214,30 @@ function _changed(key, value, version) {
214
214
  return true;
215
215
  }
216
216
 
217
+ // Conditional JSON fetch for the heavy per-page endpoints (browser-OOM fix).
218
+ // /api/knowledge (~4.5MB) and /api/work-items (~4.2MB) were re-fetched AND
219
+ // re-parsed on every 4s refresh tick — ~11MB of allocation churn per tick that
220
+ // OOM-crashed the tab during active foreground use. These endpoints emit an
221
+ // input-mtime ETag server-side (serveFreshJson + handleKnowledgeList), so send
222
+ // If-None-Match off the last-seen ETag and resolve { notModified:true } on a
223
+ // 304. Callers skip the parse + render entirely and keep their last-rendered
224
+ // state, so an idle dashboard drops from ~11MB/tick to ~0. Routes through
225
+ // safeFetch (like the /api/status poll it mirrors) for the 15s abort timeout +
226
+ // X-Minions-Dashboard-* tab/visibility correlation headers. Per-URL ETag cache;
227
+ // a non-304 error rejects so each call site's existing .catch arm still fires.
228
+ var _condFetchEtags = {};
229
+ function _condFetchJson(url) {
230
+ var headers = {};
231
+ if (_condFetchEtags[url]) headers['If-None-Match'] = _condFetchEtags[url];
232
+ return safeFetch(url, { headers }).then(function (r) {
233
+ if (r.status === 304) return { notModified: true, data: null };
234
+ if (!r.ok) return Promise.reject(new Error('HTTP ' + r.status));
235
+ var etag = r.headers && r.headers.get ? r.headers.get('etag') : null;
236
+ if (etag) _condFetchEtags[url] = etag;
237
+ return r.json().then(function (data) { return { notModified: false, data: data }; });
238
+ });
239
+ }
240
+
217
241
  function _formatCcPowerLabel(autoMode) {
218
242
  var runtime = autoMode && autoMode.ccCli ? String(autoMode.ccCli) : 'claude';
219
243
  var runtimeLabel = runtime.charAt(0).toUpperCase() + runtime.slice(1);
@@ -518,7 +542,11 @@ function _processStatusUpdate(data, opts) {
518
542
  window._lastRequestedSeq = window._lastRequestedSeq || {};
519
543
  window._lastRequestedSeq.prd = seq;
520
544
  Promise.all([
521
- fetch('/api/prd').then(function (r) { return r.ok ? r.json() : null; }),
545
+ // Conditional fetch (browser-OOM fix): flatten to null on a 304 or error,
546
+ // which the cached status/progress fallback below already handles. The
547
+ // .catch keeps a prd failure from rejecting the whole Promise.all and
548
+ // starving the verify-guides slice (matches the old `r.ok ? … : null`).
549
+ _condFetchJson('/api/prd').then(function (resp) { return resp.notModified ? null : resp.data; }).catch(function () { return null; }),
522
550
  fetch('/api/verify-guides').then(function (r) { return r.ok ? r.json() : null; }),
523
551
  ])
524
552
  .then(function (results) {
@@ -616,10 +644,12 @@ function _processStatusUpdate(data, opts) {
616
644
  const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
617
645
  window._lastRequestedSeq = window._lastRequestedSeq || {};
618
646
  window._lastRequestedSeq.prs = seq;
619
- fetch('/api/pull-requests')
620
- .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
621
- .then(function (fresh) {
647
+ _condFetchJson('/api/pull-requests')
648
+ .then(function (resp) {
622
649
  if (seq < (window._lastRequestedSeq.prs || 0)) return;
650
+ // 304 → PR list unchanged; keep last-rendered state + cross-slice sigs.
651
+ if (resp.notModified) return;
652
+ const fresh = resp.data;
623
653
  const list = Array.isArray(fresh) ? fresh : (window._lastPullRequests || []);
624
654
  const wasFirstNonEmpty = !Array.isArray(window._lastPullRequests);
625
655
  window._lastPullRequests = list;
@@ -695,11 +725,12 @@ function _processStatusUpdate(data, opts) {
695
725
  const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
696
726
  window._lastRequestedSeq = window._lastRequestedSeq || {};
697
727
  window._lastRequestedSeq.dispatch = seq;
698
- fetch('/api/dispatch')
699
- .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
700
- .then(function (fresh) {
728
+ _condFetchJson('/api/dispatch')
729
+ .then(function (resp) {
701
730
  if (seq < (window._lastRequestedSeq.dispatch || 0)) return;
702
- const d = fresh || window._lastDispatch;
731
+ // 304 dispatch queue unchanged; keep last-rendered panes + scroll.
732
+ if (resp.notModified) return;
733
+ const d = resp.data || window._lastDispatch;
703
734
  if (d) window._lastDispatch = d;
704
735
  _safeRender('dispatch', function() { renderDispatch(d, dispatchOpts); });
705
736
  // Dispatch is the third cross-slice edge: derivePlanStatus reads
@@ -785,10 +816,12 @@ function _processStatusUpdate(data, opts) {
785
816
  const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
786
817
  window._lastRequestedSeq = window._lastRequestedSeq || {};
787
818
  window._lastRequestedSeq.workItems = seq;
788
- fetch('/api/work-items')
789
- .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
790
- .then(function (fresh) {
819
+ _condFetchJson('/api/work-items')
820
+ .then(function (resp) {
791
821
  if (seq < (window._lastRequestedSeq.workItems || 0)) return;
822
+ // 304 → list unchanged; keep the last-rendered rows + DOM untouched.
823
+ if (resp.notModified) return;
824
+ const fresh = resp.data;
792
825
  const list = Array.isArray(fresh) ? fresh : (window._lastWorkItems || []);
793
826
  window._lastWorkItems = list;
794
827
  _safeRender('workItems', function() { renderWorkItems(list); });
@@ -32,13 +32,18 @@ function _formatSweepElapsed(startedAt) {
32
32
 
33
33
  async function refreshKnowledgeBase() {
34
34
  try {
35
- // Always update _kbData cross-slice readers (e.g. command-center.js
36
- // suggestion lookup) depend on it staying fresh even when the renderer is
37
- // skipped (S1).
38
- _kbData = await fetch('/api/knowledge').then(r => r.json());
39
- // Skip the full kb-list/kb-tabs rewrite when /api/knowledge returned the
40
- // same payload as last tick. Dedicated cache key 'kbPayload' avoids
41
- // colliding with any other tracked slice (S1).
35
+ // Conditional fetch (browser-OOM fix): /api/knowledge is ~4.5MB and was
36
+ // re-fetched + re-parsed every 4s tick, the single biggest contributor to
37
+ // the heap churn that crashed the tab. On a 304 the payload is unchanged,
38
+ // so _kbData (which cross-slice readers like command-center.js depend on)
39
+ // is already current skip the parse and the render entirely.
40
+ const resp = await _condFetchJson('/api/knowledge');
41
+ if (resp.notModified) return;
42
+ _kbData = resp.data;
43
+ // Second-level guard: an mtime-only ETag bust (e.g. a sweep touched a file)
44
+ // can arrive with byte-identical content — skip the full kb-list/kb-tabs
45
+ // rewrite when the payload matches last tick. Dedicated cache key
46
+ // 'kbPayload' avoids colliding with any other tracked slice (S1).
42
47
  if (typeof _changed !== 'function' || _changed('kbPayload', _kbData)) {
43
48
  renderKnowledgeBase();
44
49
  }
package/dashboard.js CHANGED
@@ -6425,6 +6425,33 @@ const server = http.createServer(async (req, res) => {
6425
6425
 
6426
6426
  async function handleKnowledgeList(req, res) {
6427
6427
  const entries = await getKnowledgeBaseEntries();
6428
+ // Content ETag (browser-OOM fix). /api/knowledge is ~4.5MB and the dashboard
6429
+ // polls it every 4s per foregrounded tab; without a 304 path the browser
6430
+ // re-downloaded + re-parsed the whole payload every tick, churning enough
6431
+ // heap to OOM-crash the tab. Derive the signature from the already-cached
6432
+ // entry metadata (count + per-file mtime/size) instead of stat-walking the
6433
+ // knowledge tree — that tree is ~9k files and the walk measured ~185ms,
6434
+ // which would block the event loop on every poll. getKnowledgeBaseEntries
6435
+ // is O(1) on a cache hit (30s TTL + explicit invalidation on KB writes and
6436
+ // sweeps), so this adds ~nothing on the hot path. Two small stats fold in
6437
+ // the sweep-badge files so the in-flight indicator still busts the tag.
6438
+ let h = 2166136261;
6439
+ for (const e of entries) {
6440
+ h = Math.imul(h ^ ((e.size || 0) >>> 0), 16777619);
6441
+ h = Math.imul(h ^ Math.floor(e.sortTs || 0), 16777619);
6442
+ }
6443
+ const sweepMtime = _maxInputMtimeMs([
6444
+ path.join(ENGINE_DIR, 'kb-swept.json'),
6445
+ path.join(ENGINE_DIR, 'kb-sweep-state.json'),
6446
+ ]);
6447
+ const kbEtag = '"knowledge-' + entries.length + '-' + (h >>> 0).toString(36) + '-' + sweepMtime + '"';
6448
+ res.setHeader('ETag', kbEtag);
6449
+ res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
6450
+ if (req && req.headers && req.headers['if-none-match'] === kbEtag) {
6451
+ res.statusCode = 304;
6452
+ res.end();
6453
+ return;
6454
+ }
6428
6455
  const result = {};
6429
6456
  for (const cat of shared.KB_CATEGORIES) result[cat] = [];
6430
6457
  for (const e of entries) {
@@ -802,13 +802,11 @@ function consolidateWithLLM(items, existingNotes, files, config) {
802
802
  const dupCheck = checkDuplicateHash(items);
803
803
  if (dupCheck.isDuplicate) {
804
804
  log('info', `Skipped LLM consolidation: ${dupCheck.count}/${dupCheck.total} items are duplicates (hash: ${dupCheck.hash.slice(0, 8)})`);
805
- // Archive duplicate files directly
806
- if (!fs.existsSync(ARCHIVE_DIR)) fs.mkdirSync(ARCHIVE_DIR, { recursive: true });
807
- for (const f of files) {
808
- try {
809
- fs.renameSync(path.join(INBOX_DIR, f), shared.uniquePath(path.join(ARCHIVE_DIR, `${dateStamp()}-${f}`)));
810
- } catch (err) { log('warn', `Inbox archive (dup skip): ${err.message}`); }
811
- }
805
+ // Archive duplicate files through the shared helper so WI references +
806
+ // completion-report artifact links get repointed to the archive path too
807
+ // (W-mq1j85cj00055a8f) the inline rename here used to skip that rewrite,
808
+ // leaving dup-note links dangling.
809
+ archiveInboxFiles(files);
812
810
  for (const f of files) _processingFiles.delete(f);
813
811
  _consolidationInFlight = false;
814
812
  _consolidationStartedAt = 0;
package/engine/shared.js CHANGED
@@ -5297,10 +5297,18 @@ function extractStructuredWorkItemPrRef(item) {
5297
5297
  // work-items.json + the central one to the new canonical location.
5298
5298
  //
5299
5299
  // Match semantics (intentionally narrow per the WI scope):
5300
- // - Scope is strictly item.references[]; description prose is NOT scanned.
5301
- // - String entries: 'notes/inbox/<inboxName>' (anchored on start or '/').
5302
- // - Object entries: { url | path | href }. Other keys (label, kind, …) are
5303
- // preserved.
5300
+ // - item.references[]: string 'notes/inbox/<inboxName>' (anchored on start
5301
+ // or '/') or object { url | path | href }. Other keys (label, kind, …)
5302
+ // are preserved. Description prose is NOT scanned.
5303
+ // - item._artifacts.notes[] (completion-report note links, written by
5304
+ // lifecycle.promoteCompletionArtifacts): the work-item renderer resolves
5305
+ // the clickable pill from note.file — a prefixed basename ('archive:<base>'
5306
+ // / 'kb:<cat>/<file>' / bare inbox base) — so we re-encode `file` for the
5307
+ // destination via _artifactNoteFileToken AND rewrite the `path` field.
5308
+ // This is the link shape agent completions actually populate, so it's the
5309
+ // one that must survive auto-archive.
5310
+ // - item.artifacts[] (raw completion artifacts): the `path` field is
5311
+ // rewritten the same way as references.
5304
5312
  // - Trailing `?query` or `#fragment` is tolerated; substring overlaps in
5305
5313
  // unrelated path segments (e.g. .../notes/inbox/foobar for foo.md) are
5306
5314
  // NOT rewritten.
@@ -5319,12 +5327,29 @@ function extractStructuredWorkItemPrRef(item) {
5319
5327
  // take the default (the in-file `mutateWorkItems` closure reference). Tests
5320
5328
  // inject a wrapper to exercise the per-file try/catch boundary without
5321
5329
  // having to manufacture a real SQL-store failure.
5330
+
5331
+ // Map a consolidation destination to the prefixed `file` token the work-item
5332
+ // artifact renderer (dashboard/js/render-work-items.js) uses for note pills:
5333
+ // notes/archive/<base> → 'archive:<base>' (openInboxNote resolves in archive)
5334
+ // knowledge/<cat>/<file> → 'kb:<cat>/<file>' (kbOpenItem)
5335
+ // notes.md / anything else → null (no per-note anchor — leave file as-is)
5336
+ function _artifactNoteFileToken(newLocation) {
5337
+ const nl = String(newLocation).replace(/\\/g, '/');
5338
+ if (/(?:^|\/)notes\/archive\//.test(nl)) return 'archive:' + nl.replace(/^.*\//, '');
5339
+ const kb = nl.match(/(?:^|\/)knowledge\/([^/]+)\/(.+)$/);
5340
+ if (kb) return 'kb:' + kb[1] + '/' + kb[2];
5341
+ return null;
5342
+ }
5343
+
5322
5344
  function rewriteInboxRefsAcrossProjects(inboxName, newLocation, opts = {}) {
5323
5345
  if (!inboxName || typeof newLocation !== 'string' || !newLocation) return 0;
5324
5346
  const baseName = String(inboxName).replace(/^.*[/\\]/, '').trim();
5325
5347
  if (!baseName) return 0;
5326
5348
  const escaped = baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
5327
5349
  const inboxRefRe = new RegExp(`(?:^|/)notes/inbox/${escaped}(?=$|[?#])`);
5350
+ // The note-pill `file` token for the destination (archive:/kb:/…). Computed
5351
+ // once — it depends only on newLocation, not the item being scanned.
5352
+ const fileToken = _artifactNoteFileToken(newLocation);
5328
5353
  const _mutate = (opts && typeof opts._mutate === 'function') ? opts._mutate : mutateWorkItems;
5329
5354
 
5330
5355
  let rewritten = 0;
@@ -5342,22 +5367,53 @@ function rewriteInboxRefsAcrossProjects(inboxName, newLocation, opts = {}) {
5342
5367
  _mutate(wiPath, items => {
5343
5368
  if (!Array.isArray(items)) return items;
5344
5369
  for (const item of items) {
5345
- if (!item || !Array.isArray(item.references)) continue;
5346
- for (let i = 0; i < item.references.length; i++) {
5347
- const ref = item.references[i];
5348
- if (typeof ref === 'string') {
5349
- if (inboxRefRe.test(ref)) {
5350
- item.references[i] = newLocation;
5351
- rewritten++;
5352
- }
5353
- } else if (ref && typeof ref === 'object') {
5354
- for (const key of ['url', 'path', 'href']) {
5355
- const v = ref[key];
5356
- if (typeof v === 'string' && inboxRefRe.test(v)) {
5357
- ref[key] = newLocation;
5370
+ if (!item) continue;
5371
+ // references[] string | { url | path | href }.
5372
+ if (Array.isArray(item.references)) {
5373
+ for (let i = 0; i < item.references.length; i++) {
5374
+ const ref = item.references[i];
5375
+ if (typeof ref === 'string') {
5376
+ if (inboxRefRe.test(ref)) {
5377
+ item.references[i] = newLocation;
5358
5378
  rewritten++;
5359
- break;
5360
5379
  }
5380
+ } else if (ref && typeof ref === 'object') {
5381
+ for (const key of ['url', 'path', 'href']) {
5382
+ const v = ref[key];
5383
+ if (typeof v === 'string' && inboxRefRe.test(v)) {
5384
+ ref[key] = newLocation;
5385
+ rewritten++;
5386
+ break;
5387
+ }
5388
+ }
5389
+ }
5390
+ }
5391
+ }
5392
+ // _artifacts.notes[] — completion-report note links. Match on the
5393
+ // inbox `path` (object form) or the bare basename `file` (the inbox
5394
+ // pill token), then re-encode `file` for the destination and fix
5395
+ // `path`. The renderer keys the clickable pill off `file`, so the
5396
+ // re-encode is what actually un-dangles the link.
5397
+ const notes = item._artifacts && item._artifacts.notes;
5398
+ if (Array.isArray(notes)) {
5399
+ for (let i = 0; i < notes.length; i++) {
5400
+ const n = notes[i];
5401
+ if (typeof n === 'string') {
5402
+ if (n === baseName && fileToken) { notes[i] = fileToken; rewritten++; }
5403
+ } else if (n && typeof n === 'object') {
5404
+ let hit = false;
5405
+ if (typeof n.path === 'string' && inboxRefRe.test(n.path)) { n.path = newLocation; hit = true; }
5406
+ if (n.file === baseName && fileToken) { n.file = fileToken; hit = true; }
5407
+ if (hit) rewritten++;
5408
+ }
5409
+ }
5410
+ }
5411
+ // artifacts[] — raw completion artifacts ({ type, path, … }).
5412
+ if (Array.isArray(item.artifacts)) {
5413
+ for (const a of item.artifacts) {
5414
+ if (a && typeof a === 'object' && typeof a.path === 'string' && inboxRefRe.test(a.path)) {
5415
+ a.path = newLocation;
5416
+ rewritten++;
5361
5417
  }
5362
5418
  }
5363
5419
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2152",
3
+ "version": "0.1.2154",
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"